From 359f84b4ad87896bcb59b0d19371089b0b1afcdc Mon Sep 17 00:00:00 2001 From: james Date: Tue, 13 Oct 2026 20:33:36 +0100 Subject: [PATCH] Add end line/column and metadata to violations (#68) Violation and AnalyzerViolation gain optional EndLine/EndColumn, and Violation gains an optional Metadata bag. Roslyn-backed models and the analyzers that report from them thread end positions through from GetLineSpan().EndLinePosition (1-based, exclusive end column). - SARIF: Region.EndLine/EndColumn when present; metadata goes into the result property bag. - JSON v1 contract: endLine, endColumn and metadata are optional additive per-violation properties; schema and RESULT_CONTRACT.md updated. Co-Authored-By: Claude Haiku 5.5 --- docs/architecture/IMPLEMENTATION_STATUS.md | 20 ++++++++ docs/architecture/RESULT_CONTRACT.md | 10 +++- .../AnalysisModel/CallSiteModel.cs | 4 +- .../AnalysisModel/DiagnosticModel.cs | 4 +- .../AnalysisModel/MemberModels.cs | 16 ++++-- .../AnalysisModel/SyntaxFacts.cs | 20 ++++++-- .../AnalysisModel/TypeModel.cs | 4 +- .../RoslynDiagnosticExtractor.cs | 4 +- .../RoslynTypeExtractor.cs | 34 +++++++++---- .../SyntaxFactWalker.cs | 29 ++++++----- .../Evaluation/RuleEvaluator.cs | 32 +++++++----- .../Results/ValidationResult.cs | 5 +- .../Analyzers/CatchClauseCountAnalyzer.cs | 2 + .../CompanionTypeCardinalityAnalyzer.cs | 2 + .../DuplicateAttributeArgumentAnalyzer.cs | 12 +++-- .../Analyzers/ExhaustiveSwitchAnalyzer.cs | 2 + .../Analyzers/ImmutableMutationAnalyzer.cs | 2 + .../Analyzers/MemberOrderingAnalyzer.cs | 12 +++-- .../Analyzers/NoExceptionsAnalyzer.cs | 2 + .../NoPureDelegationOverrideAnalyzer.cs | 2 + .../RoslynDiagnosticPassthroughAnalyzer.cs | 2 + .../Contracts/ValidationReportMapper.cs | 5 +- .../Contracts/ValidationReportV1.cs | 5 +- .../Sarif/SarifViolationReporter.cs | 31 +++++++++-- .../Schemas/validation-report.v1.schema.json | 7 +++ .../Analyzers/AnalyzerViolation.cs | 4 +- .../RoslynTypeExtractorTests.cs | 25 +++++++++ .../Reporting/JsonViolationReporterTests.cs | 51 +++++++++++++++++++ .../Reporting/SarifViolationReporterTests.cs | 49 ++++++++++++++++++ .../Reporting/ValidationReportMapperTests.cs | 19 +++++++ .../Reporting/ValidationReportSchemaTests.cs | 22 ++++++++ 31 files changed, 370 insertions(+), 68 deletions(-) diff --git a/docs/architecture/IMPLEMENTATION_STATUS.md b/docs/architecture/IMPLEMENTATION_STATUS.md index 06f5e5d..c02313d 100644 --- a/docs/architecture/IMPLEMENTATION_STATUS.md +++ b/docs/architecture/IMPLEMENTATION_STATUS.md @@ -745,6 +745,26 @@ Decisions and gotchas: - `JsonSchema.Net` is referenced by `CodeGuard.Core.Tests` only, to validate output. Each schema is parsed once in a static `Lazy`, because `JsonSchema.FromText` throws on a duplicate `$id`. +### Post-v1 addition: violation end positions and metadata (#68) + +`Violation` (and `AnalyzerViolation`) gained optional `EndLine`/`EndColumn` (`int?`, default `null`) +and `Violation` gained `Metadata` (`IReadOnlyDictionary?`, default `null`). Existing +constructors and call sites still compile. + +- End positions come from `GetLineSpan().EndLinePosition`, using the same 1-based convention as the + start. The end column is exclusive, the column just past the last character, which matches SARIF + `region.endColumn`. They are threaded through the Roslyn-backed models (`TypeModel`, `MethodModel`, + `PropertyModel`, `ConstructorModel`, `FieldModel`, `CallSiteModel`, `DiagnosticModel`, and the syntax + fact models `SwitchModel`, `ThrowSiteModel`, `TryBlockModel`, `MethodBodyShapeModel`, + `MutationSiteModel`) and into the analyzers that produce violations from them. +- Span coverage: type and member end positions cover the declaring identifier, because that is the + symbol location. Syntax fact models carry only `EndLine`/`EndColumn`, not a start column. Repository, + project, file and MSBuild-derived violations have no end span. +- SARIF: `Region.EndLine`/`EndColumn` are set only when present. `Metadata` entries go into the result's + property bag under their own keys. +- JSON (v1 contract): `endLine`, `endColumn` and `metadata` are optional additive properties on each + violation, documented in `RESULT_CONTRACT.md` and the schema. No built-in rule populates `Metadata` yet. + ## The 11 starter rules All under `rules/`, all illustrative (`Contoso.*` namespace, `illustrative: true`), matching the diff --git a/docs/architecture/RESULT_CONTRACT.md b/docs/architecture/RESULT_CONTRACT.md index ccb32bd..895e309 100644 --- a/docs/architecture/RESULT_CONTRACT.md +++ b/docs/architecture/RESULT_CONTRACT.md @@ -54,8 +54,14 @@ object has `additionalProperties: false`, so a consumer can rely on the set of k } ``` -Optional fields (`file`, `line`, `column`, `symbol`, `project`, `remediation`, and the optional -fields of `evaluationErrors` and `analysisWarnings`) are omitted when unknown, not written as `null`. +Optional fields (`file`, `line`, `column`, `endLine`, `endColumn`, `metadata`, `symbol`, `project`, +`remediation`, and the optional fields of `evaluationErrors` and `analysisWarnings`) are omitted when +unknown, not written as `null`. + +Per violation, `endLine` and `endColumn` give the end of the source span when the producer knows it +(currently Roslyn-backed rules). `endColumn` is 1-based and exclusive, i.e. the column just past the last +character, matching SARIF `region.endColumn`. `metadata` is an optional string-to-string bag for +producer-specific details; no built-in rule populates it yet. ## Top-level fields diff --git a/src/CodeGuard.Analysis/AnalysisModel/CallSiteModel.cs b/src/CodeGuard.Analysis/AnalysisModel/CallSiteModel.cs index 1a3d5f2..6676344 100644 --- a/src/CodeGuard.Analysis/AnalysisModel/CallSiteModel.cs +++ b/src/CodeGuard.Analysis/AnalysisModel/CallSiteModel.cs @@ -22,4 +22,6 @@ public sealed record CallSiteModel( int Column, string? EnclosingComparisonOperator = null, string? EnclosingComparisonValue = null, - string ProjectPath = ""); + string ProjectPath = "", + int? EndLine = null, + int? EndColumn = null); diff --git a/src/CodeGuard.Analysis/AnalysisModel/DiagnosticModel.cs b/src/CodeGuard.Analysis/AnalysisModel/DiagnosticModel.cs index 55cb44c..977e87b 100644 --- a/src/CodeGuard.Analysis/AnalysisModel/DiagnosticModel.cs +++ b/src/CodeGuard.Analysis/AnalysisModel/DiagnosticModel.cs @@ -6,4 +6,6 @@ public sealed record DiagnosticModel( string ProjectName, string FilePath, int Line, - int Column); + int Column, + int? EndLine = null, + int? EndColumn = null); diff --git a/src/CodeGuard.Analysis/AnalysisModel/MemberModels.cs b/src/CodeGuard.Analysis/AnalysisModel/MemberModels.cs index 57161e9..dccb9ac 100644 --- a/src/CodeGuard.Analysis/AnalysisModel/MemberModels.cs +++ b/src/CodeGuard.Analysis/AnalysisModel/MemberModels.cs @@ -11,7 +11,9 @@ public sealed record MethodModel( string ProjectName, string FilePath, int Line, - int Column); + int Column, + int? EndLine = null, + int? EndColumn = null); public sealed record PropertyModel( string Name, @@ -28,7 +30,9 @@ public sealed record PropertyModel( string ProjectName, string FilePath, int Line, - int Column); + int Column, + int? EndLine = null, + int? EndColumn = null); public sealed record ConstructorModel( Accessibility Accessibility, @@ -38,7 +42,9 @@ public sealed record ConstructorModel( string ProjectName, string FilePath, int Line, - int Column); + int Column, + int? EndLine = null, + int? EndColumn = null); public sealed record FieldModel( string Name, @@ -51,7 +57,9 @@ public sealed record FieldModel( string FilePath, int Line, int Column, - string? ConstantValue = null); + string? ConstantValue = null, + int? EndLine = null, + int? EndColumn = null); public sealed record ParameterModel( string Name, diff --git a/src/CodeGuard.Analysis/AnalysisModel/SyntaxFacts.cs b/src/CodeGuard.Analysis/AnalysisModel/SyntaxFacts.cs index 7be772e..1a31a4b 100644 --- a/src/CodeGuard.Analysis/AnalysisModel/SyntaxFacts.cs +++ b/src/CodeGuard.Analysis/AnalysisModel/SyntaxFacts.cs @@ -7,7 +7,9 @@ public sealed record SwitchModel( IReadOnlyList ArmLabels, bool HasDefaultOrDiscardArm, string FilePath, - int Line); + int Line, + int? EndLine = null, + int? EndColumn = null); public sealed record ThrowSiteModel( string ContainingMethod, @@ -16,7 +18,9 @@ public sealed record ThrowSiteModel( string? ExceptionTypeName, bool IsFirstStatementInMethod, string FilePath, - int Line); + int Line, + int? EndLine = null, + int? EndColumn = null); public sealed record MutationSiteModel( string ContainingMethod, @@ -25,7 +29,9 @@ public sealed record MutationSiteModel( string ProjectName, string FilePath, int Line, - string ProjectPath = ""); + string ProjectPath = "", + int? EndLine = null, + int? EndColumn = null); public sealed record TryBlockModel( string ContainingMethod, @@ -34,7 +40,9 @@ public sealed record TryBlockModel( int CatchClauseCount, IReadOnlyList CatchTypeNames, string FilePath, - int Line); + int Line, + int? EndLine = null, + int? EndColumn = null); public sealed record MethodBodyShapeModel( string ContainingMethod, @@ -44,4 +52,6 @@ public sealed record MethodBodyShapeModel( bool IsSingleBaseCallDelegation, string FilePath, int Line, - string ProjectPath = ""); + string ProjectPath = "", + int? EndLine = null, + int? EndColumn = null); diff --git a/src/CodeGuard.Analysis/AnalysisModel/TypeModel.cs b/src/CodeGuard.Analysis/AnalysisModel/TypeModel.cs index 551dd35..3c6a69f 100644 --- a/src/CodeGuard.Analysis/AnalysisModel/TypeModel.cs +++ b/src/CodeGuard.Analysis/AnalysisModel/TypeModel.cs @@ -18,7 +18,9 @@ public sealed record TypeModel( string FilePath, int Line, int Column, - string ProjectPath = ""); + string ProjectPath = "", + int? EndLine = null, + int? EndColumn = null); public enum TypeKind { diff --git a/src/CodeGuard.Analyzers.Roslyn/RoslynDiagnosticExtractor.cs b/src/CodeGuard.Analyzers.Roslyn/RoslynDiagnosticExtractor.cs index 94b0939..bea1df1 100644 --- a/src/CodeGuard.Analyzers.Roslyn/RoslynDiagnosticExtractor.cs +++ b/src/CodeGuard.Analyzers.Roslyn/RoslynDiagnosticExtractor.cs @@ -25,7 +25,9 @@ public static IReadOnlyList Extract(CSharpCompilation compilati projectName, lineSpan.Path, lineSpan.StartLinePosition.Line + 1, - lineSpan.StartLinePosition.Character + 1); + lineSpan.StartLinePosition.Character + 1, + lineSpan.EndLinePosition.Line + 1, + lineSpan.EndLinePosition.Character + 1); }) .ToList(); } diff --git a/src/CodeGuard.Analyzers.Roslyn/RoslynTypeExtractor.cs b/src/CodeGuard.Analyzers.Roslyn/RoslynTypeExtractor.cs index 958ee8a..0ac80f8 100644 --- a/src/CodeGuard.Analyzers.Roslyn/RoslynTypeExtractor.cs +++ b/src/CodeGuard.Analyzers.Roslyn/RoslynTypeExtractor.cs @@ -69,7 +69,9 @@ private static TypeModel ToTypeModel(INamedTypeSymbol symbol, string projectName FilePath: location?.SourceTree?.FilePath ?? string.Empty, Line: (lineSpan?.StartLinePosition.Line ?? -1) + 1, Column: (lineSpan?.StartLinePosition.Character ?? -1) + 1, - ProjectPath: projectPath); + ProjectPath: projectPath, + EndLine: lineSpan?.EndLinePosition.Line + 1, + EndColumn: lineSpan?.EndLinePosition.Character + 1); } private static TypeKind MapTypeKind(INamedTypeSymbol symbol) => symbol.TypeKind switch @@ -131,19 +133,23 @@ private static bool IsPartial(INamedTypeSymbol symbol) => _ => constant.Value?.ToString() ?? "null" }; - private static (string FilePath, int Line, int Column) GetLocation(ISymbol symbol) + // End positions use the same 1-based convention as the start: Roslyn's EndLinePosition is the + // exclusive end of the span, so +1 yields the column just past the last character (SARIF endColumn semantics). + private static (string FilePath, int Line, int Column, int? EndLine, int? EndColumn) GetLocation(ISymbol symbol) { var location = symbol.Locations.FirstOrDefault(l => l.IsInSource); var lineSpan = location?.GetLineSpan(); return ( location?.SourceTree?.FilePath ?? string.Empty, (lineSpan?.StartLinePosition.Line ?? -1) + 1, - (lineSpan?.StartLinePosition.Character ?? -1) + 1); + (lineSpan?.StartLinePosition.Character ?? -1) + 1, + lineSpan?.EndLinePosition.Line + 1, + lineSpan?.EndLinePosition.Character + 1); } private static MethodModel ToMethodModel(IMethodSymbol method, string declaringType, string projectName) { - var (filePath, line, column) = GetLocation(method); + var (filePath, line, column, endLine, endColumn) = GetLocation(method); return new MethodModel( Name: method.Name, ReturnType: method.ReturnType.ToDisplayString(), @@ -155,7 +161,9 @@ private static MethodModel ToMethodModel(IMethodSymbol method, string declaringT ProjectName: projectName, FilePath: filePath, Line: line, - Column: column); + Column: column, + EndLine: endLine, + EndColumn: endColumn); } private static MethodModifiers MapMethodModifiers(IMethodSymbol method) @@ -171,7 +179,7 @@ private static MethodModifiers MapMethodModifiers(IMethodSymbol method) private static PropertyModel ToPropertyModel(IPropertySymbol property, string declaringType, string projectName) { - var (filePath, line, column) = GetLocation(property); + var (filePath, line, column, endLine, endColumn) = GetLocation(property); return new PropertyModel( Name: property.Name, Type: property.Type.ToDisplayString(), @@ -187,12 +195,14 @@ private static PropertyModel ToPropertyModel(IPropertySymbol property, string de ProjectName: projectName, FilePath: filePath, Line: line, - Column: column); + Column: column, + EndLine: endLine, + EndColumn: endColumn); } private static ConstructorModel ToConstructorModel(IMethodSymbol constructor, string declaringType, string projectName) { - var (filePath, line, column) = GetLocation(constructor); + var (filePath, line, column, endLine, endColumn) = GetLocation(constructor); return new ConstructorModel( Accessibility: MapAccessibility(constructor.DeclaredAccessibility), Parameters: constructor.Parameters.Select(ToParameterModel).ToList(), @@ -201,12 +211,14 @@ private static ConstructorModel ToConstructorModel(IMethodSymbol constructor, st ProjectName: projectName, FilePath: filePath, Line: line, - Column: column); + Column: column, + EndLine: endLine, + EndColumn: endColumn); } private static FieldModel ToFieldModel(IFieldSymbol field, string declaringType, string projectName) { - var (filePath, line, column) = GetLocation(field); + var (filePath, line, column, endLine, endColumn) = GetLocation(field); return new FieldModel( Name: field.Name, Type: field.Type.ToDisplayString(), @@ -218,6 +230,8 @@ private static FieldModel ToFieldModel(IFieldSymbol field, string declaringType, FilePath: filePath, Line: line, Column: column, + EndLine: endLine, + EndColumn: endColumn, ConstantValue: field.HasConstantValue ? field.ConstantValue?.ToString() : null); } diff --git a/src/CodeGuard.Analyzers.Roslyn/SyntaxFactWalker.cs b/src/CodeGuard.Analyzers.Roslyn/SyntaxFactWalker.cs index 4db1713..1726779 100644 --- a/src/CodeGuard.Analyzers.Roslyn/SyntaxFactWalker.cs +++ b/src/CodeGuard.Analyzers.Roslyn/SyntaxFactWalker.cs @@ -119,7 +119,7 @@ private void RecordCallSite(SyntaxNode node, CallSiteKind kind, SeparatedSyntaxL }) .ToList(); - var (containingMethod, containingType, filePath, line, column) = DescribeLocation(node); + var (containingMethod, containingType, filePath, line, column, endLine, endColumn) = DescribeLocation(node); var (comparisonOperator, comparisonValue) = kind == CallSiteKind.Invocation ? TryGetEnclosingComparison(node) : (null, null); @@ -135,6 +135,8 @@ private void RecordCallSite(SyntaxNode node, CallSiteKind kind, SeparatedSyntaxL FilePath: filePath, Line: line, Column: column, + EndLine: endLine, + EndColumn: endColumn, EnclosingComparisonOperator: comparisonOperator, EnclosingComparisonValue: comparisonValue, ProjectPath: projectPath)); @@ -156,28 +158,28 @@ private void RecordCallSite(SyntaxNode node, CallSiteKind kind, SeparatedSyntaxL private void RecordSwitch(SyntaxNode node, IReadOnlyList armLabels, bool hasDefaultOrDiscard) { - var (containingMethod, containingType, filePath, line, _) = DescribeLocation(node); - sink.AddSwitch(new SwitchModel(containingMethod, containingType, projectName, armLabels, hasDefaultOrDiscard, filePath, line)); + var (containingMethod, containingType, filePath, line, _, endLine, endColumn) = DescribeLocation(node); + sink.AddSwitch(new SwitchModel(containingMethod, containingType, projectName, armLabels, hasDefaultOrDiscard, filePath, line, endLine, endColumn)); } private void RecordThrowSite(SyntaxNode node, ExpressionSyntax? expression) { var exceptionTypeName = expression is not null ? semanticModel.GetTypeInfo(expression).Type?.ToDisplayString() : null; var isFirst = IsFirstStatementInEnclosingBlock(node); - var (containingMethod, containingType, filePath, line, _) = DescribeLocation(node); - sink.AddThrowSite(new ThrowSiteModel(containingMethod, containingType, projectName, exceptionTypeName, isFirst, filePath, line)); + var (containingMethod, containingType, filePath, line, _, endLine, endColumn) = DescribeLocation(node); + sink.AddThrowSite(new ThrowSiteModel(containingMethod, containingType, projectName, exceptionTypeName, isFirst, filePath, line, endLine, endColumn)); } private void RecordMutationSite(SyntaxNode node, string targetMemberName) { - var (containingMethod, containingType, filePath, line, _) = DescribeLocation(node); - sink.AddMutationSite(new MutationSiteModel(containingMethod, containingType, targetMemberName, projectName, filePath, line, projectPath)); + var (containingMethod, containingType, filePath, line, _, endLine, endColumn) = DescribeLocation(node); + sink.AddMutationSite(new MutationSiteModel(containingMethod, containingType, targetMemberName, projectName, filePath, line, projectPath, endLine, endColumn)); } private void RecordTryBlock(SyntaxNode node, int catchClauseCount, IReadOnlyList catchTypeNames) { - var (containingMethod, containingType, filePath, line, _) = DescribeLocation(node); - sink.AddTryBlock(new TryBlockModel(containingMethod, containingType, projectName, catchClauseCount, catchTypeNames, filePath, line)); + var (containingMethod, containingType, filePath, line, _, endLine, endColumn) = DescribeLocation(node); + sink.AddTryBlock(new TryBlockModel(containingMethod, containingType, projectName, catchClauseCount, catchTypeNames, filePath, line, endLine, endColumn)); } private void RecordMethodBodyShape(MethodDeclarationSyntax node) @@ -208,7 +210,8 @@ private void RecordMethodBodyShape(MethodDeclarationSyntax node) sink.AddMethodBodyShape(new MethodBodyShapeModel( node.Identifier.Text, containingType, projectName, statementCount, isSingleBaseCallDelegation, - location.SourceTree?.FilePath ?? string.Empty, lineSpan.StartLinePosition.Line + 1, projectPath)); + location.SourceTree?.FilePath ?? string.Empty, lineSpan.StartLinePosition.Line + 1, projectPath, + lineSpan.EndLinePosition.Line + 1, lineSpan.EndLinePosition.Character + 1)); } private static bool IsBaseDelegationStatement(StatementSyntax statement, string methodName) => statement switch @@ -234,7 +237,7 @@ private static bool IsFirstStatementInEnclosingBlock(SyntaxNode node) return statement?.Parent is not BlockSyntax block || block.Statements.FirstOrDefault() == statement; } - private (string ContainingMethod, string ContainingType, string FilePath, int Line, int Column) DescribeLocation(SyntaxNode node) + private (string ContainingMethod, string ContainingType, string FilePath, int Line, int Column, int? EndLine, int? EndColumn) DescribeLocation(SyntaxNode node) { var enclosingSymbol = semanticModel.GetEnclosingSymbol(node.SpanStart); var containingMethod = (enclosingSymbol as IMethodSymbol)?.Name ?? enclosingSymbol?.Name ?? ""; @@ -248,7 +251,9 @@ private static bool IsFirstStatementInEnclosingBlock(SyntaxNode node) containingType, location.SourceTree?.FilePath ?? string.Empty, lineSpan.StartLinePosition.Line + 1, - lineSpan.StartLinePosition.Character + 1); + lineSpan.StartLinePosition.Character + 1, + lineSpan.EndLinePosition.Line + 1, + lineSpan.EndLinePosition.Character + 1); } private static string DescribeLabel(SwitchLabelSyntax label) => label switch diff --git a/src/CodeGuard.Core/Evaluation/RuleEvaluator.cs b/src/CodeGuard.Core/Evaluation/RuleEvaluator.cs index 9fae6ef..691e9be 100644 --- a/src/CodeGuard.Core/Evaluation/RuleEvaluator.cs +++ b/src/CodeGuard.Core/Evaluation/RuleEvaluator.cs @@ -165,7 +165,9 @@ private static void EvaluateAnalyzerRule(RuleDefinition rule, RepositoryModel mo analyzerViolation.Symbol, analyzerViolation.ProjectName, rule.Remediation, - rule.Documentation)); + rule.Documentation, + EndLine: analyzerViolation.EndLine, + EndColumn: analyzerViolation.EndColumn)); } } @@ -200,7 +202,7 @@ private static void EvaluateSelectorRule( private static Violation CreateViolation(RuleDefinition rule, object candidate, AssertionOutcome outcome) { - var (file, line, column, symbol, project) = ExtractLocation(candidate); + var (file, line, column, endLine, endColumn, symbol, project) = ExtractLocation(candidate); return new Violation( rule.Id, @@ -213,21 +215,23 @@ private static Violation CreateViolation(RuleDefinition rule, object candidate, symbol, project, rule.Remediation, - rule.Documentation); + rule.Documentation, + EndLine: endLine, + EndColumn: endColumn); } - private static (string? File, int? Line, int? Column, string? Symbol, string? Project) ExtractLocation(object candidate) => + private static (string? File, int? Line, int? Column, int? EndLine, int? EndColumn, string? Symbol, string? Project) ExtractLocation(object candidate) => candidate switch { - TypeModel type => (type.FilePath, type.Line, type.Column, type.FullName, type.ProjectName), - ProjectModel project => (project.Path, null, null, project.Name, project.Name), - FileModel file => (file.Path, null, null, file.RelativePath, null), - RepositoryModel repository => (repository.RootPath, null, null, null, null), - MethodModel method => (method.FilePath, method.Line, method.Column, $"{method.DeclaringType}.{method.Name}", method.ProjectName), - PropertyModel property => (property.FilePath, property.Line, property.Column, $"{property.DeclaringType}.{property.Name}", property.ProjectName), - ConstructorModel constructor => (constructor.FilePath, constructor.Line, constructor.Column, constructor.DeclaringType, constructor.ProjectName), - FieldModel field => (field.FilePath, field.Line, field.Column, $"{field.DeclaringType}.{field.Name}", field.ProjectName), - CallSiteModel callSite => (callSite.FilePath, callSite.Line, callSite.Column, callSite.InvokedMember, callSite.ProjectName), - _ => (null, null, null, null, null) + TypeModel type => (type.FilePath, type.Line, type.Column, type.EndLine, type.EndColumn, type.FullName, type.ProjectName), + ProjectModel project => (project.Path, null, null, null, null, project.Name, project.Name), + FileModel file => (file.Path, null, null, null, null, file.RelativePath, null), + RepositoryModel repository => (repository.RootPath, null, null, null, null, null, null), + MethodModel method => (method.FilePath, method.Line, method.Column, method.EndLine, method.EndColumn, $"{method.DeclaringType}.{method.Name}", method.ProjectName), + PropertyModel property => (property.FilePath, property.Line, property.Column, property.EndLine, property.EndColumn, $"{property.DeclaringType}.{property.Name}", property.ProjectName), + ConstructorModel constructor => (constructor.FilePath, constructor.Line, constructor.Column, constructor.EndLine, constructor.EndColumn, constructor.DeclaringType, constructor.ProjectName), + FieldModel field => (field.FilePath, field.Line, field.Column, field.EndLine, field.EndColumn, $"{field.DeclaringType}.{field.Name}", field.ProjectName), + CallSiteModel callSite => (callSite.FilePath, callSite.Line, callSite.Column, callSite.EndLine, callSite.EndColumn, callSite.InvokedMember, callSite.ProjectName), + _ => (null, null, null, null, null, null, null) }; } diff --git a/src/CodeGuard.Core/Results/ValidationResult.cs b/src/CodeGuard.Core/Results/ValidationResult.cs index 8f3f2e4..ec7e0f8 100644 --- a/src/CodeGuard.Core/Results/ValidationResult.cs +++ b/src/CodeGuard.Core/Results/ValidationResult.cs @@ -34,7 +34,10 @@ public sealed record Violation( string? Symbol, string? Project, string? Remediation, - IReadOnlyList DocumentationReferences); + IReadOnlyList DocumentationReferences, + int? EndLine = null, + int? EndColumn = null, + IReadOnlyDictionary? Metadata = null); /// A rule whose selector/assertion/analyzer threw instead of producing a pass/fail result. public sealed record RuleEvaluationError( diff --git a/src/CodeGuard.Evaluation/Analyzers/CatchClauseCountAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/CatchClauseCountAnalyzer.cs index 38da83e..547af03 100644 --- a/src/CodeGuard.Evaluation/Analyzers/CatchClauseCountAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/CatchClauseCountAnalyzer.cs @@ -25,5 +25,7 @@ public IEnumerable Analyze(RepositoryModel model) => Message: $"Try block in {t.ContainingType}.{t.ContainingMethod} has {t.CatchClauseCount} catch clauses; try blocks in this scope must have between {minCatches} and {maxCatches}.", FilePath: t.FilePath, Line: t.Line, + EndLine: t.EndLine, + EndColumn: t.EndColumn, ProjectName: t.ProjectName)); } diff --git a/src/CodeGuard.Evaluation/Analyzers/CompanionTypeCardinalityAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/CompanionTypeCardinalityAnalyzer.cs index 8277a34..5d043d1 100644 --- a/src/CodeGuard.Evaluation/Analyzers/CompanionTypeCardinalityAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/CompanionTypeCardinalityAnalyzer.cs @@ -39,6 +39,8 @@ public IEnumerable Analyze(RepositoryModel model) Message: $"{markerType.FullName} has {matchingCompanions} type(s) named '{expectedCompanionName}'; expected exactly one.", FilePath: markerType.FilePath, Line: markerType.Line, + EndLine: markerType.EndLine, + EndColumn: markerType.EndColumn, ProjectName: markerType.ProjectName); } } diff --git a/src/CodeGuard.Evaluation/Analyzers/DuplicateAttributeArgumentAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/DuplicateAttributeArgumentAnalyzer.cs index 38b07e5..7cf8faf 100644 --- a/src/CodeGuard.Evaluation/Analyzers/DuplicateAttributeArgumentAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/DuplicateAttributeArgumentAnalyzer.cs @@ -36,16 +36,18 @@ from reservation in Collect(type) Message: $"{r.Symbol} has attribute argument value '{r.Value}', which is already used elsewhere in the repository.", FilePath: r.FilePath, Line: r.Line, + EndLine: r.EndLine, + EndColumn: r.EndColumn, ProjectName: r.ProjectName)); } private IEnumerable Collect(TypeModel type) => - type.Methods.Select(m => (Symbol: $"{type.FullName}.{m.Name}", m.Attributes, m.FilePath, m.Line, m.ProjectName)) - .Concat(type.Properties.Select(p => (Symbol: $"{type.FullName}.{p.Name}", p.Attributes, p.FilePath, p.Line, p.ProjectName))) - .Concat(type.Fields.Select(f => (Symbol: $"{type.FullName}.{f.Name}", f.Attributes, f.FilePath, f.Line, f.ProjectName))) + type.Methods.Select(m => (Symbol: $"{type.FullName}.{m.Name}", m.Attributes, m.FilePath, m.Line, m.ProjectName, m.EndLine, m.EndColumn)) + .Concat(type.Properties.Select(p => (Symbol: $"{type.FullName}.{p.Name}", p.Attributes, p.FilePath, p.Line, p.ProjectName, p.EndLine, p.EndColumn))) + .Concat(type.Fields.Select(f => (Symbol: $"{type.FullName}.{f.Name}", f.Attributes, f.FilePath, f.Line, f.ProjectName, f.EndLine, f.EndColumn))) .SelectMany(member => member.Attributes .Where(a => GlobMatcher.IsMatch(a.TypeName, attributeNamePattern) && a.ConstructorArgumentLiterals.Count > argumentIndex) - .Select(a => new Reservation(member.Symbol, a.ConstructorArgumentLiterals[argumentIndex], member.FilePath, member.Line, member.ProjectName))); + .Select(a => new Reservation(member.Symbol, a.ConstructorArgumentLiterals[argumentIndex], member.FilePath, member.Line, member.ProjectName, member.EndLine, member.EndColumn))); - private readonly record struct Reservation(string Symbol, string Value, string FilePath, int Line, string ProjectName); + private readonly record struct Reservation(string Symbol, string Value, string FilePath, int Line, string ProjectName, int? EndLine, int? EndColumn); } diff --git a/src/CodeGuard.Evaluation/Analyzers/ExhaustiveSwitchAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/ExhaustiveSwitchAnalyzer.cs index 580fc18..2007517 100644 --- a/src/CodeGuard.Evaluation/Analyzers/ExhaustiveSwitchAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/ExhaustiveSwitchAnalyzer.cs @@ -22,5 +22,7 @@ public IEnumerable Analyze(RepositoryModel model) => Message: $"Switch in {s.ContainingType}.{s.ContainingMethod} has no default/discard arm and cannot be proven exhaustive.", FilePath: s.FilePath, Line: s.Line, + EndLine: s.EndLine, + EndColumn: s.EndColumn, ProjectName: s.ProjectName)); } diff --git a/src/CodeGuard.Evaluation/Analyzers/ImmutableMutationAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/ImmutableMutationAnalyzer.cs index 509ffaf..fb24034 100644 --- a/src/CodeGuard.Evaluation/Analyzers/ImmutableMutationAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/ImmutableMutationAnalyzer.cs @@ -31,6 +31,8 @@ public IEnumerable Analyze(RepositoryModel model) Message: $"Mutation of {m.ContainingType}.{m.TargetMemberName} outside its constructor violates record immutability.", FilePath: m.FilePath, Line: m.Line, + EndLine: m.EndLine, + EndColumn: m.EndColumn, ProjectName: m.ProjectName)); } } diff --git a/src/CodeGuard.Evaluation/Analyzers/MemberOrderingAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/MemberOrderingAnalyzer.cs index 6a180e7..458b82f 100644 --- a/src/CodeGuard.Evaluation/Analyzers/MemberOrderingAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/MemberOrderingAnalyzer.cs @@ -32,10 +32,10 @@ public IEnumerable Analyze(RepositoryModel model) foreach (var type in types) { var members = type.Fields - .Select(f => new MemberEntry(RankOf("fields"), f.Line, f.FilePath, f.ProjectName, $"{type.FullName}.{f.Name}")) - .Concat(type.Constructors.Select(c => new MemberEntry(RankOf("constructors"), c.Line, c.FilePath, c.ProjectName, $"{type.FullName}..ctor"))) - .Concat(type.Properties.Select(p => new MemberEntry(RankOf("properties"), p.Line, p.FilePath, p.ProjectName, $"{type.FullName}.{p.Name}"))) - .Concat(type.Methods.Select(m => new MemberEntry(RankOf("methods"), m.Line, m.FilePath, m.ProjectName, $"{type.FullName}.{m.Name}"))) + .Select(f => new MemberEntry(RankOf("fields"), f.Line, f.FilePath, f.ProjectName, $"{type.FullName}.{f.Name}", f.EndLine, f.EndColumn)) + .Concat(type.Constructors.Select(c => new MemberEntry(RankOf("constructors"), c.Line, c.FilePath, c.ProjectName, $"{type.FullName}..ctor", c.EndLine, c.EndColumn))) + .Concat(type.Properties.Select(p => new MemberEntry(RankOf("properties"), p.Line, p.FilePath, p.ProjectName, $"{type.FullName}.{p.Name}", p.EndLine, p.EndColumn))) + .Concat(type.Methods.Select(m => new MemberEntry(RankOf("methods"), m.Line, m.FilePath, m.ProjectName, $"{type.FullName}.{m.Name}", m.EndLine, m.EndColumn))) .OrderBy(m => m.Line) .ToList(); @@ -48,6 +48,8 @@ public IEnumerable Analyze(RepositoryModel model) Message: $"{member.Symbol} is declared out of the configured member order.", FilePath: member.FilePath, Line: member.Line, + EndLine: member.EndLine, + EndColumn: member.EndColumn, ProjectName: member.ProjectName); break; } @@ -59,5 +61,5 @@ public IEnumerable Analyze(RepositoryModel model) private int RankOf(string group) => _rankByGroup.TryGetValue(group, out var rank) ? rank : int.MaxValue; - private readonly record struct MemberEntry(int Rank, int Line, string FilePath, string ProjectName, string Symbol); + private readonly record struct MemberEntry(int Rank, int Line, string FilePath, string ProjectName, string Symbol, int? EndLine, int? EndColumn); } diff --git a/src/CodeGuard.Evaluation/Analyzers/NoExceptionsAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/NoExceptionsAnalyzer.cs index 699ef43..be597fb 100644 --- a/src/CodeGuard.Evaluation/Analyzers/NoExceptionsAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/NoExceptionsAnalyzer.cs @@ -27,5 +27,7 @@ public IEnumerable Analyze(RepositoryModel model) => : $"Throw in {t.ContainingType}.{t.ContainingMethod} is not allowed in this scope.", FilePath: t.FilePath, Line: t.Line, + EndLine: t.EndLine, + EndColumn: t.EndColumn, ProjectName: t.ProjectName)); } diff --git a/src/CodeGuard.Evaluation/Analyzers/NoPureDelegationOverrideAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/NoPureDelegationOverrideAnalyzer.cs index 47e5a22..2266680 100644 --- a/src/CodeGuard.Evaluation/Analyzers/NoPureDelegationOverrideAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/NoPureDelegationOverrideAnalyzer.cs @@ -36,6 +36,8 @@ public IEnumerable Analyze(RepositoryModel model) Message: $"{m.ContainingType}.{m.ContainingMethod} is a pure delegation override of its base class; remove it instead of overriding.", FilePath: m.FilePath, Line: m.Line, + EndLine: m.EndLine, + EndColumn: m.EndColumn, ProjectName: m.ProjectName)); } } diff --git a/src/CodeGuard.Evaluation/Analyzers/RoslynDiagnosticPassthroughAnalyzer.cs b/src/CodeGuard.Evaluation/Analyzers/RoslynDiagnosticPassthroughAnalyzer.cs index 08aeb02..e3ee85e 100644 --- a/src/CodeGuard.Evaluation/Analyzers/RoslynDiagnosticPassthroughAnalyzer.cs +++ b/src/CodeGuard.Evaluation/Analyzers/RoslynDiagnosticPassthroughAnalyzer.cs @@ -23,6 +23,8 @@ public IEnumerable Analyze(RepositoryModel model) => Message: $"{d.Id}: {d.Message}", FilePath: d.FilePath, Line: d.Line, + EndLine: d.EndLine, + EndColumn: d.EndColumn, Column: d.Column, ProjectName: d.ProjectName)); } diff --git a/src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs b/src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs index 06b3ec6..8729292 100644 --- a/src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs +++ b/src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs @@ -82,7 +82,10 @@ public static string ResolveToolVersion(Assembly assembly) Symbol: v.Symbol, Project: v.Project, Remediation: v.Remediation, - DocumentationReferences: v.DocumentationReferences); + DocumentationReferences: v.DocumentationReferences, + EndLine: v.EndLine, + EndColumn: v.EndColumn, + Metadata: v.Metadata); private static SeverityCountsV1 CountBySeverity(IReadOnlyList violations) { diff --git a/src/CodeGuard.Reporting/Contracts/ValidationReportV1.cs b/src/CodeGuard.Reporting/Contracts/ValidationReportV1.cs index 8b3fd17..95cc26b 100644 --- a/src/CodeGuard.Reporting/Contracts/ValidationReportV1.cs +++ b/src/CodeGuard.Reporting/Contracts/ValidationReportV1.cs @@ -39,7 +39,10 @@ public sealed record ViolationV1( string? Symbol, string? Project, string? Remediation, - IReadOnlyList DocumentationReferences); + IReadOnlyList DocumentationReferences, + int? EndLine = null, + int? EndColumn = null, + IReadOnlyDictionary? Metadata = null); public sealed record EvaluationErrorV1( string RuleId, diff --git a/src/CodeGuard.Reporting/Sarif/SarifViolationReporter.cs b/src/CodeGuard.Reporting/Sarif/SarifViolationReporter.cs index e27b821..11032d7 100644 --- a/src/CodeGuard.Reporting/Sarif/SarifViolationReporter.cs +++ b/src/CodeGuard.Reporting/Sarif/SarifViolationReporter.cs @@ -81,6 +81,15 @@ private static Result ToSarifResult(Violation violation) result.Locations = [ToLocation(violation)]; } + // Violation metadata has no fixed SARIF home, so it goes into the result's property bag as-is. + if (violation.Metadata is not null) + { + foreach (var (key, value) in violation.Metadata) + { + result.SetProperty(key, value); + } + } + return result; } @@ -103,12 +112,28 @@ private static Result ToSarifResult(Violation violation) PhysicalLocation = new PhysicalLocation { ArtifactLocation = new ArtifactLocation { Uri = new Uri(violation.File!, UriKind.RelativeOrAbsolute) }, - Region = violation.Line is null - ? null - : new Region { StartLine = violation.Line.Value, StartColumn = violation.Column ?? 1 } + Region = violation.Line is null ? null : ToRegion(violation) } }; + // SARIF's Region.EndLine/EndColumn are non-nullable ints, so they are only set when the violation carries an end span. + // Without one the region has just the start, which is still valid SARIF. + private static Region ToRegion(Violation violation) + { + var region = new Region { StartLine = violation.Line!.Value, StartColumn = violation.Column ?? 1 }; + if (violation.EndLine is { } endLine) + { + region.EndLine = endLine; + } + + if (violation.EndColumn is { } endColumn) + { + region.EndColumn = endColumn; + } + + return region; + } + private static FailureLevel ToFailureLevel(Severity severity) => severity switch { Severity.Info => FailureLevel.Note, diff --git a/src/CodeGuard.Reporting/Schemas/validation-report.v1.schema.json b/src/CodeGuard.Reporting/Schemas/validation-report.v1.schema.json index f4441a9..c102a4d 100644 --- a/src/CodeGuard.Reporting/Schemas/validation-report.v1.schema.json +++ b/src/CodeGuard.Reporting/Schemas/validation-report.v1.schema.json @@ -106,6 +106,13 @@ "file": { "type": "string", "description": "Repository-relative or absolute file path, when known." }, "line": { "type": "integer", "minimum": 0, "description": "1-based line; 0 when the location is unknown." }, "column": { "type": "integer", "minimum": 0, "description": "1-based column; 0 when the location is unknown." }, + "endLine": { "type": "integer", "minimum": 1, "description": "1-based line of the end of the violation's span. Present only when the producer knows the span." }, + "endColumn": { "type": "integer", "minimum": 1, "description": "1-based column just past the end of the span (exclusive, SARIF endColumn semantics). Present only with endLine." }, + "metadata": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Free-form string key/value details attached by the producer. Omitted when there are none." + }, "symbol": { "type": "string" }, "project": { "type": "string" }, "remediation": { "type": "string" }, diff --git a/src/CodeGuard.RuleModel/Analyzers/AnalyzerViolation.cs b/src/CodeGuard.RuleModel/Analyzers/AnalyzerViolation.cs index 726acff..0726561 100644 --- a/src/CodeGuard.RuleModel/Analyzers/AnalyzerViolation.cs +++ b/src/CodeGuard.RuleModel/Analyzers/AnalyzerViolation.cs @@ -6,4 +6,6 @@ public sealed record AnalyzerViolation( int? Line = null, int? Column = null, string? Symbol = null, - string? ProjectName = null); + string? ProjectName = null, + int? EndLine = null, + int? EndColumn = null); diff --git a/tests/CodeGuard.Analyzers.Roslyn.Tests/RoslynTypeExtractorTests.cs b/tests/CodeGuard.Analyzers.Roslyn.Tests/RoslynTypeExtractorTests.cs index 0bfecf7..8777197 100644 --- a/tests/CodeGuard.Analyzers.Roslyn.Tests/RoslynTypeExtractorTests.cs +++ b/tests/CodeGuard.Analyzers.Roslyn.Tests/RoslynTypeExtractorTests.cs @@ -419,4 +419,29 @@ public void Save(string name, int quantity = 1) { } var name = method.Parameters.Single(p => p.Name == "name"); Assert.False(name.HasDefaultValue); } + + [Fact] + public void ExtractTypes_PopulatesEndPositionsFromSourceSpan() + { + var compilation = CompilationFactory.Create(""" + namespace Contoso.Domain; + public class Order + { + public void Ship() { } + } + """); + var types = RoslynTypeExtractor.ExtractTypes(compilation, "Contoso.Domain"); + var order = types.Single(t => t.FullName == "Contoso.Domain.Order"); + var ship = order.Methods.Single(m => m.Name == "Ship"); + + // Type and method locations are their identifier tokens; End is the 1-based column just past the last character. + Assert.Equal(2, order.Line); + Assert.Equal(14, order.Column); + Assert.Equal(2, order.EndLine); + Assert.Equal(19, order.EndColumn); + Assert.Equal(4, ship.Line); + Assert.Equal(17, ship.Column); + Assert.Equal(4, ship.EndLine); + Assert.Equal(21, ship.EndColumn); + } } diff --git a/tests/CodeGuard.Core.Tests/Reporting/JsonViolationReporterTests.cs b/tests/CodeGuard.Core.Tests/Reporting/JsonViolationReporterTests.cs index a6e2826..af0acae 100644 --- a/tests/CodeGuard.Core.Tests/Reporting/JsonViolationReporterTests.cs +++ b/tests/CodeGuard.Core.Tests/Reporting/JsonViolationReporterTests.cs @@ -133,4 +133,55 @@ public async Task WriteAsync_AnalysisWarningsEmpty_WhenNoneProvided() using var document = JsonDocument.Parse(output); Assert.Empty(document.RootElement.GetProperty("analysisWarnings").EnumerateArray()); } + + [Fact] + public async Task WriteAsync_IncludesEndPositionAndMetadata_WhenPresent() + { + var result = new ValidationResult( + Status: ValidationStatus.Failed, RulesEvaluated: 1, RulesPassed: 0, RulesFailed: 1, RulesErrored: 0, + Violations: + [ + new Violation( + RuleId: "DDD-ENTITY-001", RuleVersion: 1, Severity: Severity.Error, Message: "msg", + File: "LegacyThing.cs", Line: 5, Column: 14, Symbol: null, Project: null, Remediation: null, + DocumentationReferences: [], EndLine: 7, EndColumn: 2, + Metadata: new Dictionary { ["expectedBase"] = "Entity" }) + ], + EvaluationErrors: [], + EvaluatedAtUtc: DateTimeOffset.UtcNow); + + var writer = new StringWriter(); + await new JsonViolationReporter().WriteAsync(result, writer); + using var document = JsonDocument.Parse(writer.ToString()); + var violation = document.RootElement.GetProperty("violations")[0]; + + Assert.Equal(7, violation.GetProperty("endLine").GetInt32()); + Assert.Equal(2, violation.GetProperty("endColumn").GetInt32()); + Assert.Equal("Entity", violation.GetProperty("metadata").GetProperty("expectedBase").GetString()); + } + + [Fact] + public async Task WriteAsync_OmitsEndPositionAndMetadata_WhenAbsent() + { + var result = new ValidationResult( + Status: ValidationStatus.Failed, RulesEvaluated: 1, RulesPassed: 0, RulesFailed: 1, RulesErrored: 0, + Violations: + [ + new Violation( + RuleId: "DDD-ENTITY-001", RuleVersion: 1, Severity: Severity.Error, Message: "msg", + File: "LegacyThing.cs", Line: 5, Column: 14, Symbol: null, Project: null, Remediation: null, + DocumentationReferences: []) + ], + EvaluationErrors: [], + EvaluatedAtUtc: DateTimeOffset.UtcNow); + + var writer = new StringWriter(); + await new JsonViolationReporter().WriteAsync(result, writer); + using var document = JsonDocument.Parse(writer.ToString()); + var violation = document.RootElement.GetProperty("violations")[0]; + + Assert.False(violation.TryGetProperty("endLine", out _)); + Assert.False(violation.TryGetProperty("endColumn", out _)); + Assert.False(violation.TryGetProperty("metadata", out _)); + } } diff --git a/tests/CodeGuard.Core.Tests/Reporting/SarifViolationReporterTests.cs b/tests/CodeGuard.Core.Tests/Reporting/SarifViolationReporterTests.cs index 67d6df9..05e4392 100644 --- a/tests/CodeGuard.Core.Tests/Reporting/SarifViolationReporterTests.cs +++ b/tests/CodeGuard.Core.Tests/Reporting/SarifViolationReporterTests.cs @@ -132,4 +132,53 @@ public async Task WriteAsync_MapsAnalysisWarningsToToolExecutionNotifications() Assert.Equal("MSBUILD-WORKSPACE", notification.GetProperty("descriptor").GetProperty("id").GetString()); Assert.Equal("Unable to find fallback package folder 'X'.", notification.GetProperty("message").GetProperty("text").GetString()); } + + [Fact] + public async Task WriteAsync_MapsEndPositionsAndMetadata() + { + var result = CreateResult(new Violation( + RuleId: "DDD-ENTITY-001", RuleVersion: 1, Severity: Severity.Error, Message: "msg", + File: "LegacyThing.cs", Line: 5, Column: 14, Symbol: null, Project: null, Remediation: null, + DocumentationReferences: [], EndLine: 5, EndColumn: 22, + Metadata: new Dictionary { ["expectedBase"] = "Entity" })); + + var sarifResult = await WriteSingleResultAsync(result); + + var region = sarifResult.GetProperty("locations")[0].GetProperty("physicalLocation").GetProperty("region"); + Assert.Equal(5, region.GetProperty("startLine").GetInt32()); + Assert.Equal(14, region.GetProperty("startColumn").GetInt32()); + Assert.Equal(5, region.GetProperty("endLine").GetInt32()); + Assert.Equal(22, region.GetProperty("endColumn").GetInt32()); + Assert.Equal("Entity", sarifResult.GetProperty("properties").GetProperty("expectedBase").GetString()); + } + + [Fact] + public async Task WriteAsync_WithoutEndSpan_EmitsStartOnlyRegion() + { + var result = CreateResult(new Violation( + RuleId: "DDD-ENTITY-001", RuleVersion: 1, Severity: Severity.Error, Message: "msg", + File: "LegacyThing.cs", Line: 5, Column: 14, Symbol: null, Project: null, Remediation: null, + DocumentationReferences: [])); + + var sarifResult = await WriteSingleResultAsync(result); + + var region = sarifResult.GetProperty("locations")[0].GetProperty("physicalLocation").GetProperty("region"); + Assert.Equal(5, region.GetProperty("startLine").GetInt32()); + Assert.Equal(14, region.GetProperty("startColumn").GetInt32()); + Assert.False(region.TryGetProperty("endLine", out _)); + Assert.False(region.TryGetProperty("endColumn", out _)); + Assert.False(sarifResult.TryGetProperty("properties", out _)); + } + + private static ValidationResult CreateResult(Violation violation) => new( + Status: ValidationStatus.Failed, RulesEvaluated: 1, RulesPassed: 0, RulesFailed: 1, RulesErrored: 0, + Violations: [violation], EvaluationErrors: [], EvaluatedAtUtc: DateTimeOffset.UtcNow); + + private static async Task WriteSingleResultAsync(ValidationResult result) + { + var writer = new StringWriter(); + await new SarifViolationReporter().WriteAsync(result, writer); + using var document = JsonDocument.Parse(writer.ToString()); + return document.RootElement.GetProperty("runs")[0].GetProperty("results")[0].Clone(); + } } diff --git a/tests/CodeGuard.Core.Tests/Reporting/ValidationReportMapperTests.cs b/tests/CodeGuard.Core.Tests/Reporting/ValidationReportMapperTests.cs index 1e4d118..c05b2ad 100644 --- a/tests/CodeGuard.Core.Tests/Reporting/ValidationReportMapperTests.cs +++ b/tests/CodeGuard.Core.Tests/Reporting/ValidationReportMapperTests.cs @@ -129,4 +129,23 @@ public void ResolveToolVersion_StripsBuildMetadataSuffix() Violations: violations ?? [], EvaluationErrors: [], EvaluatedAtUtc: DateTimeOffset.UtcNow); + + [Fact] + public void ToV1_MapsEndPositionAndMetadata() + { + var violation = new Violation( + RuleId: "R-1", RuleVersion: 1, Severity: Severity.Error, Message: "msg", + File: "F.cs", Line: 1, Column: 2, Symbol: null, Project: null, Remediation: null, + DocumentationReferences: [], EndLine: 3, EndColumn: 4, + Metadata: new Dictionary { ["k"] = "v" }); + var result = new ValidationResult( + Status: ValidationStatus.Failed, RulesEvaluated: 1, RulesPassed: 0, RulesFailed: 1, RulesErrored: 0, + Violations: [violation], EvaluationErrors: [], EvaluatedAtUtc: DateTimeOffset.UtcNow); + + var mapped = ValidationReportMapper.ToV1(result, TimeSpan.Zero, "1.0.0").Violations.Single(); + + Assert.Equal(3, mapped.EndLine); + Assert.Equal(4, mapped.EndColumn); + Assert.Equal("v", mapped.Metadata!["k"]); + } } diff --git a/tests/CodeGuard.Core.Tests/Reporting/ValidationReportSchemaTests.cs b/tests/CodeGuard.Core.Tests/Reporting/ValidationReportSchemaTests.cs index 72da757..6a5c53e 100644 --- a/tests/CodeGuard.Core.Tests/Reporting/ValidationReportSchemaTests.cs +++ b/tests/CodeGuard.Core.Tests/Reporting/ValidationReportSchemaTests.cs @@ -132,4 +132,26 @@ private static string LoadEmbeddedSchemaText() using var reader = new StreamReader(stream); return reader.ReadToEnd(); } + + [Fact] + public async Task Output_WithEndPositionAndMetadata_ConformsToSchema() + { + var result = new ValidationResult( + Status: ValidationStatus.Failed, RulesEvaluated: 1, RulesPassed: 0, RulesFailed: 1, RulesErrored: 0, + Violations: + [ + new Violation( + RuleId: "DDD-ENTITY-001", RuleVersion: 1, Severity: Severity.Error, + Message: "msg", File: "LegacyThing.cs", Line: 5, Column: 14, + Symbol: null, Project: null, Remediation: null, DocumentationReferences: [], + EndLine: 5, EndColumn: 22, + Metadata: new Dictionary { ["expectedBase"] = "Entity" }) + ], + EvaluationErrors: [], + EvaluatedAtUtc: DateTimeOffset.UtcNow); + + var output = await WriteAsync(result, TimeSpan.Zero); + + AssertConforms(output); + } }