Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e242698
Centralize shell output and preserve machine-mode contracts
mkrueger Oct 16, 2026
5635e4c
Preserve required prompts and theme validation diagnostics
mkrueger Oct 16, 2026
813b0f5
Enforce console clearing policy and preserve quiet editor history
mkrueger Oct 16, 2026
7031125
Preserve machine-mode write and output error details
mkrueger Oct 16, 2026
7292df4
Bound machine-mode import diagnostics
mkrueger Oct 19, 2026
fd07563
Merge main; bound bulk-write diagnostics and honor 2> for shell output
mkrueger Oct 19, 2026
52c408b
Use the executing shell for command output and disable CI ANSI enrichers
mkrueger Oct 19, 2026
1b660ca
Return client bucket selection and honor interactive 2> for diagnostics
mkrueger Oct 19, 2026
b18b3c1
Classify info RBAC permission diagnostics as errors
mkrueger Oct 19, 2026
ce37d9b
Address code-quality findings in new tests
mkrueger Oct 19, 2026
967b637
Defer changelog updates in #254 until release preparation
mkrueger Nov 5, 2026
06789d7
Merge main and preserve centralized shell output in #254
mkrueger Nov 5, 2026
2881cd4
Preserve redirected batch failure diagnostics in #254
mkrueger Nov 5, 2026
3dda774
Localize bulk item diagnostic prefixes in #254
mkrueger Nov 5, 2026
40202bb
Reuse the existing bulk diagnostic localization entry
mkrueger Nov 5, 2026
fa626a4
Use executing shell output for bucket selection in #254
mkrueger Nov 5, 2026
ef059e4
Merge latest main output and service-error fixes into #254
mkrueger Nov 5, 2026
ef687a2
Merge history redaction while preserving centralized output in #254
mkrueger Nov 5, 2026
a4a4344
Merge 3acd215 usage and machine-error fixes into #254
mkrueger Nov 5, 2026
1df4478
Classify bucket and watch argument failures as usage errors in #254
mkrueger Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;

namespace CosmosShell.Analyzer
{
/// <summary>
/// Requires user-facing console output to go through ShellOutput, which applies the
/// quiet and machine-mode presentation policies. Direct writes bypass those
/// policies and can corrupt structured stdout.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class DirectConsoleOutputAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "CZ0003";

private const string Category = "Usage";
private const string AllowedType = "Azure.Data.Cosmos.Shell.Core.ShellOutput";

private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
DiagnosticId,
"Direct console output",
"'{0}' bypasses the shell output policy; write through ShellOutput instead",
Category,
DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "User-facing output must go through ShellOutput so --quiet and machine mode are honored.");

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression);
}

private static void Analyze(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;
if (!(context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is IMethodSymbol method))
{
return;
}

if (!IsDirectOutput(context.SemanticModel, invocation, method, context))
{
return;
}

var containingType = context.ContainingSymbol?.ContainingType;
while (containingType != null)
{
if (containingType.ToDisplayString() == AllowedType)
{
return;
}

containingType = containingType.ContainingType;
}

context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.GetLocation(), method.ContainingType.Name + "." + method.Name));
}

private static bool IsDirectOutput(SemanticModel model, InvocationExpressionSyntax invocation, IMethodSymbol method, SyntaxNodeAnalysisContext context)
{
var type = method.ContainingType?.ToDisplayString();

// Console.Write / Console.WriteLine / Console.Clear.
if (method.IsStatic && type == "System.Console" && (method.Name.StartsWith("Write") || method.Name == "Clear"))
{
return true;
}

// Every static AnsiConsole call except Create renders to the global console.
if (method.IsStatic && type == "Spectre.Console.AnsiConsole" && method.Name != "Create")
{
return true;
}

// Console.Out.Write*, Console.Error.Write*, and AnsiConsole.Console.<extension>.
if (invocation.Expression is MemberAccessExpressionSyntax access
&& model.GetSymbolInfo(access.Expression, context.CancellationToken).Symbol is IPropertySymbol receiver
&& receiver.IsStatic)
{
var owner = receiver.ContainingType?.ToDisplayString();
if (owner == "System.Console" && (receiver.Name == "Out" || receiver.Name == "Error") && method.Name.StartsWith("Write"))
{
return true;
}

if (owner == "Spectre.Console.AnsiConsole" && receiver.Name == "Console")
{
return true;
}
}

return false;
}
}
}
1 change: 1 addition & 0 deletions CosmosDBShell.Analyzer/GlobalSuppressions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@

[assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "<Pending>", Scope = "member", Target = "~F:CosmosShell.Analyzer.EmptyCatchClauseAnalyzer.Rule")]
[assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "<Pending>", Scope = "member", Target = "~F:CosmosShell.Analyzer.WriteLineAnalyzer.Rule")]
[assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "<Pending>", Scope = "member", Target = "~F:CosmosShell.Analyzer.DirectConsoleOutputAnalyzer.Rule")]
7 changes: 4 additions & 3 deletions CosmosDBShell.Tests/CommandTests/BatchCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ await Assert.ThrowsAsync<CommandException>(
public async Task Execute_WithoutOperations_Throws()
{
await Assert.ThrowsAsync<CommandException>(
() => BatchExecutor.ExecuteAsync("batch", null!, default, [], CancellationToken.None));
() => BatchExecutor.ExecuteAsync(null!, "batch", null!, default, [], CancellationToken.None));
}

[Fact]
Expand All @@ -259,7 +259,7 @@ public async Task Execute_OverMaximumOperations_Throws()
var operations = Enumerable.Repeat(operation, BatchExecutor.MaxOperations + 1).ToArray();

await Assert.ThrowsAsync<CommandException>(
() => BatchExecutor.ExecuteAsync("batch", null!, default, operations, CancellationToken.None));
() => BatchExecutor.ExecuteAsync(null!, "batch", null!, default, operations, CancellationToken.None));
}

[Fact]
Expand All @@ -271,7 +271,8 @@ public void CreateResultState_PreservesBatchResultAndRequestCharge()
requestCharge = 4.25,
});

var state = BatchExecutor.CreateResultState(summary, "Batch committed.", 4.25);
using var shell = ShellInterpreter.CreateInstance();
var state = BatchExecutor.CreateResultState(shell, summary, "Batch committed.", 4.25);

Assert.Equal(4.25, state.RequestCharge);
var result = Assert.IsType<ShellJson>(state.Result).Value;
Expand Down
73 changes: 70 additions & 3 deletions CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,81 @@

namespace CosmosShell.Tests.CommandTests;

using System.Text.Json;
using Azure.Data.Cosmos.Shell.Commands;
using Azure.Data.Cosmos.Shell.Core;
using Azure.Data.Cosmos.Shell.Parser;
using Azure.Data.Cosmos.Shell.States;
using Azure.Data.Cosmos.Shell.Util;
using Microsoft.Azure.Cosmos;
using Spectre.Console;

/// <summary>
/// Unit tests for <see cref="BucketCommand"/>. Covers the pure validation helper and
/// the offline state visitors that do not require a live Cosmos DB connection.
/// </summary>
[Collection(ConsoleOutputTestCollection.Name)]
public class BucketCommandTests
{
[Theory]
[InlineData(null, false)]
[InlineData("3", false)]
[InlineData("0", false)]
[InlineData(null, true)]
[InlineData("3", true)]
[InlineData("0", true)]
public async Task ClientSelection_UsesExecutingShellOutput(string? action, bool machineMode)
{
using var shell = ShellInterpreter.CreateInstance();
using var client = CreateTestClient();
client.ClientOptions.ThroughputBucket = 2;
shell.State = new DatabaseState("TestDatabase", client);
shell.Options = new Program.CosmosShellOptions { Output = machineMode ? "json" : "user", Quiet = machineMode };
var singletonOptions = ShellInterpreter.Instance.Options;
var originalOut = Console.Out;
var originalConsole = AnsiConsole.Console;
using var output = new StringWriter();
try
{
ShellInterpreter.Instance.Options = new Program.CosmosShellOptions { Output = machineMode ? "user" : "json", Quiet = !machineMode };
Console.SetOut(output);
AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors,
Out = new AnsiConsoleOutput(output),
});
var state = await new BucketCommand { Action = action }.ExecuteAsync(
shell, new CommandState(), "bucket", TestContext.Current.CancellationToken);
shell.PrintState(state);

if (machineMode)
{
using var result = JsonDocument.Parse(output.ToString());
var selection = result.RootElement.GetProperty("clientBucket");
if (action == "0")
{
Assert.Equal(JsonValueKind.Null, selection.ValueKind);
}
else
{
Assert.Equal(action == "3" ? 3 : 2, selection.GetInt32());
}
}
else
{
Assert.NotEmpty(output.ToString());
Assert.DoesNotContain("clientBucket", output.ToString());
}
}
finally
{
Console.SetOut(originalOut);
AnsiConsole.Console = originalConsole;
ShellInterpreter.Instance.Options = singletonOptions;
}
}

[Theory]
[InlineData(0)]
[InlineData(1)]
Expand Down Expand Up @@ -68,6 +131,7 @@ public async Task ExecuteAsync_InDatabase_SetsBucket()

Assert.False(state.IsError);
Assert.Equal(3, client.ClientOptions.ThroughputBucket);
Assert.Equal(3, Assert.IsType<ShellJson>(state.Result).Value.GetProperty("clientBucket").GetInt32());
}

[Fact]
Expand All @@ -83,32 +147,35 @@ public async Task ExecuteAsync_InDatabase_ZeroResetsBucket()

Assert.False(state.IsError);
Assert.Null(client.ClientOptions.ThroughputBucket);
Assert.Equal(JsonValueKind.Null, Assert.IsType<ShellJson>(state.Result).Value.GetProperty("clientBucket").ValueKind);
}

[Fact]
public async Task ExecuteAsync_InContainer_NoArgs_ShowsCurrent()
{
var client = CreateTestClient();
client.ClientOptions.ThroughputBucket = 2;
using var shell = ShellInterpreter.CreateInstance();
shell.State = new ContainerState("TestContainer", "TestDatabase", client);
var command = new BucketCommand();

var state = await command.ExecuteAsync(shell, new CommandState(), "bucket", CancellationToken.None);

Assert.False(state.IsError);
Assert.Equal(2, Assert.IsType<ShellJson>(state.Result).Value.GetProperty("clientBucket").GetInt32());
}

[Fact]
public async Task ExecuteAsync_InDatabase_InvalidValue_ReturnsEmptyState()
public async Task ExecuteAsync_InDatabase_InvalidValue_ThrowsCommandException()
{
var client = CreateTestClient();
using var shell = ShellInterpreter.CreateInstance();
shell.State = new DatabaseState("TestDatabase", client);
var command = new BucketCommand { Action = "99" };

var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None);
await Assert.ThrowsAsync<CommandException>(
() => command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None));

Assert.False(state.IsError);
Assert.Null(client.ClientOptions.ThroughputBucket);
}

Expand Down
60 changes: 60 additions & 0 deletions CosmosDBShell.Tests/CommandTests/CommandStatusOutputTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,66 @@ namespace CosmosShell.Tests.CommandTests;
[Collection(ConsoleOutputTestCollection.Name)]
public class CommandStatusOutputTests
{
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task BatchFailure_ErrorRedirectPreservesSingleDiagnostic(bool append, bool positional)
{
using var shell = ShellInterpreter.CreateInstance();
shell.Options = new Program.CosmosShellOptions { Output = "user", Verbose = !positional };
var file = Path.GetTempFileName();
var originalOut = Console.Out;
using var output = new StringWriter();
try
{
Console.SetOut(output);
await File.WriteAllTextAsync(file, "existing\n", TestContext.Current.CancellationToken);
shell.ErrOutRedirect = file;
shell.AppendErrRedirection = append;
var container = Substitute.For<Container>();
var batch = Substitute.For<TransactionalBatch>();
using var response = Substitute.For<TransactionalBatchResponse>();
response.StatusCode.Returns(HttpStatusCode.Conflict);
response.IsSuccessStatusCode.Returns(false);
response.RequestCharge.Returns(1.25);
container.CreateTransactionalBatch(Arg.Any<PartitionKey>()).Returns(batch);
batch.ExecuteAsync(Arg.Any<CancellationToken>()).Returns(response);
var operations = BatchOperationParser.Parse("batch", """{"op":"delete","id":"missing"}""");
var state = Assert.IsType<StructuredErrorCommandState>(await BatchExecutor.ExecuteAsync(
shell, "batch", container, new PartitionKey("pk"), operations, TestContext.Current.CancellationToken));
if (positional)
{
state.Exception = new PositionalException("batch.csh", state.Exception, 4, 7, "batch execute");
}

shell.PrintState(state);

var content = await File.ReadAllTextAsync(file, TestContext.Current.CancellationToken);
Assert.Empty(output.ToString());
Assert.Equal(append, content.StartsWith("existing", StringComparison.Ordinal));
if (positional)
{
Assert.Contains("batch.csh:4:7", content);
}
else
{
Assert.Contains("CommandException", content);
}

var beforeRenderer = content;
state.RenderUser!();
Assert.Equal(beforeRenderer, await File.ReadAllTextAsync(file, TestContext.Current.CancellationToken));
Assert.Empty(output.ToString());
}
finally
{
Console.SetOut(originalOut);
File.Delete(file);
}
}

[Theory]
[InlineData(".json", false)]
[InlineData(".csv", false)]
Expand Down
4 changes: 4 additions & 0 deletions CosmosDBShell.Tests/CommandTests/CommandUsageErrorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ public async Task EveryBuiltInCommand_InvalidBindingIsUsageError(Type type, stri
[InlineData("rm")]
[InlineData("rm '*' --etag value")]
[InlineData("watch --interval=NaN")]
[InlineData("bucket 99")]
[InlineData("bucket -1")]
[InlineData("query 'SELECT * FROM c' --bucket=99")]
[InlineData("query 'SELECT * FROM c' --bucket=-1")]
public async Task CommandValidation_ReturnsUsageError(string text)
{
using var shell = ShellInterpreter.CreateInstance();
Expand Down
Loading
Loading