Добавлено формирование детальной ошибки
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Microsoft.SqlServer.TransactSql.ScriptDom;
|
||||
using System.Text;
|
||||
|
||||
namespace SQLLinter.Common;
|
||||
|
||||
@@ -6,6 +7,16 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
|
||||
{
|
||||
protected readonly List<Violation> _violations = new();
|
||||
|
||||
protected Dictionary<TSqlFragment, TSqlFragment?> _parents
|
||||
= new Dictionary<TSqlFragment, TSqlFragment?>();
|
||||
|
||||
public void SetParents(Dictionary<TSqlFragment, TSqlFragment?> parents)
|
||||
=> _parents = parents;
|
||||
|
||||
protected TSqlFragment? GetParent(TSqlFragment node)
|
||||
=> _parents.TryGetValue(node, out var parent) ? parent : null;
|
||||
|
||||
|
||||
public int DynamicSqlStartColumn { get; set; }
|
||||
public int DynamicSqlStartLine { get; set; }
|
||||
public virtual string Name { get => GetDefaultRuleName(GetType().Name); }
|
||||
@@ -57,16 +68,314 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
|
||||
|
||||
protected void AddViolation(string RuleName, string Template, int Line, int Column, params string[] param)
|
||||
{
|
||||
_violations.Add(new(RuleName, Template, Line, Column, param));
|
||||
_violations.Add(new(RuleName, Template, Line, Column, null, param));
|
||||
}
|
||||
|
||||
protected void AddViolation(TSqlFragment node, params string[] param)
|
||||
{
|
||||
_violations.Add(new(this.Name, this.Text, GetLineNumber(node), GetColumnNumber(node), param));
|
||||
var context = FindContextBlock(node);
|
||||
var block = ExtractBlock(context, node);
|
||||
|
||||
_violations.Add(new(this.Name, this.Text, GetLineNumber(node), GetColumnNumber(node), block, param));
|
||||
}
|
||||
|
||||
protected string GetText(params string[] param)
|
||||
{
|
||||
return string.Format(this.Text, param);
|
||||
}
|
||||
}
|
||||
|
||||
private TSqlFragment? FindContextBlock(TSqlFragment node)
|
||||
{
|
||||
var current = node;
|
||||
|
||||
while (current != null)
|
||||
{
|
||||
switch (current)
|
||||
{
|
||||
// SELECT-блоки
|
||||
case QuerySpecification:
|
||||
case QueryDerivedTable:
|
||||
case ScalarSubquery:
|
||||
case QualifiedJoin:
|
||||
case UnqualifiedJoin:
|
||||
return current;
|
||||
|
||||
// Таблицы и функции в FROM
|
||||
case NamedTableReference:
|
||||
case SchemaObjectFunctionTableReference:
|
||||
return current;
|
||||
|
||||
// ---- Новые блоки для SqlDataTypeReference ----
|
||||
|
||||
// Определение столбца
|
||||
case ColumnDefinition:
|
||||
return current;
|
||||
|
||||
// DECLARE @x INT
|
||||
case DeclareVariableStatement:
|
||||
return current;
|
||||
|
||||
// Параметры процедур/функций
|
||||
case ProcedureParameter:
|
||||
return current;
|
||||
|
||||
// CAST(x AS INT)
|
||||
case CastCall:
|
||||
case ConvertCall:
|
||||
return current;
|
||||
}
|
||||
|
||||
current = GetParent(current);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Вспомогательные классы
|
||||
public class ExtractedBlock
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public int StartLine { get; set; }
|
||||
public List<(int LineNumber, string Text)> Lines { get; set; } = new();
|
||||
|
||||
public int ErrorStartLine { get; set; }
|
||||
public int ErrorStartColumn { get; set; }
|
||||
public int ErrorEndLine { get; set; }
|
||||
public int ErrorEndColumn { get; set; }
|
||||
public int MinIndent { get; set; }
|
||||
}
|
||||
|
||||
private class LineInfo
|
||||
{
|
||||
public int OriginalLineNumber { get; set; }
|
||||
public string Text { get; set; } = "";
|
||||
public int OriginalIndent { get; set; }
|
||||
public int StartOffset { get; set; }
|
||||
public int EndOffset { get; set; }
|
||||
}
|
||||
|
||||
protected ExtractedBlock? ExtractBlock(TSqlFragment? node, TSqlFragment errorNode)
|
||||
{
|
||||
if (node == null || node.ScriptTokenStream == null)
|
||||
return null;
|
||||
|
||||
// 1. Получаем токены для блока
|
||||
var tokens = node.ScriptTokenStream
|
||||
.Where(t => t.Line >= node.StartLine &&
|
||||
t.Offset < node.StartOffset + node.FragmentLength)
|
||||
.ToList();
|
||||
|
||||
if (tokens.Count == 0)
|
||||
return null;
|
||||
|
||||
// 2. Строим строки из токенов
|
||||
var lines = BuildLinesFromTokens(tokens);
|
||||
|
||||
// 3. Вычисляем минимальный отступ
|
||||
int minIndent = CalculateMinIndent(lines);
|
||||
|
||||
// 4. Нормализуем строки (убираем общий отступ)
|
||||
var normalizedLines = NormalizeLines(lines, minIndent);
|
||||
|
||||
// 5. Вычисляем позиции ошибки
|
||||
var (errorStartIdx, errorStartCol, errorEndIdx, errorEndCol) =
|
||||
CalculateErrorPositionsSimple(errorNode, lines, normalizedLines, minIndent);
|
||||
|
||||
// 6. Формируем результат
|
||||
return new ExtractedBlock
|
||||
{
|
||||
Text = string.Join("\n", normalizedLines.Select(l => l.Text)),
|
||||
StartLine = tokens.First().Line,
|
||||
Lines = normalizedLines,
|
||||
ErrorStartLine = errorStartIdx + 1,
|
||||
ErrorStartColumn = errorStartCol,
|
||||
ErrorEndLine = errorEndIdx + 1,
|
||||
ErrorEndColumn = errorEndCol
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строит список строк из токенов
|
||||
/// </summary>
|
||||
private List<(int LineNumber, string Text)> BuildLinesFromTokens(List<TSqlParserToken> tokens)
|
||||
{
|
||||
var result = new List<(int LineNumber, string Text)>();
|
||||
|
||||
if (tokens.Count == 0)
|
||||
return result;
|
||||
|
||||
var currentLine = new StringBuilder();
|
||||
int currentLineNumber = tokens[0].Line;
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
// Если токен на новой строке, сохраняем предыдущую строку
|
||||
if (token.Line > currentLineNumber)
|
||||
{
|
||||
result.Add((currentLineNumber, currentLine.ToString()));
|
||||
currentLine.Clear();
|
||||
currentLineNumber = token.Line;
|
||||
|
||||
// Добавляем пробелы для выравнивания
|
||||
if (token.Column > 1)
|
||||
{
|
||||
currentLine.Append(' ', token.Column - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем текст токена
|
||||
currentLine.Append(token.Text);
|
||||
}
|
||||
|
||||
// Добавляем последнюю строку
|
||||
result.Add((currentLineNumber, currentLine.ToString()));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Вычисляет минимальный отступ среди непустых строк
|
||||
/// </summary>
|
||||
private int CalculateMinIndent(List<(int LineNumber, string Text)> lines)
|
||||
{
|
||||
int minIndent = int.MaxValue;
|
||||
|
||||
foreach (var (lineNumber, text) in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
continue;
|
||||
|
||||
int indent = 0;
|
||||
while (indent < text.Length && char.IsWhiteSpace(text[indent]))
|
||||
indent++;
|
||||
|
||||
if (indent < minIndent)
|
||||
minIndent = indent;
|
||||
}
|
||||
|
||||
return minIndent == int.MaxValue ? 0 : minIndent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует строки, убирая минимальный отступ
|
||||
/// </summary>
|
||||
private List<(int LineNumber, string Text)> NormalizeLines(
|
||||
List<(int LineNumber, string Text)> lines, int minIndent)
|
||||
{
|
||||
var result = new List<(int LineNumber, string Text)>();
|
||||
|
||||
foreach (var (lineNumber, text) in lines)
|
||||
{
|
||||
string normalizedText;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
normalizedText = text;
|
||||
}
|
||||
else if (text.Length >= minIndent)
|
||||
{
|
||||
normalizedText = text.Substring(minIndent);
|
||||
}
|
||||
else
|
||||
{
|
||||
normalizedText = text;
|
||||
}
|
||||
|
||||
result.Add((lineNumber, normalizedText));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Вычисляет позиции ошибки (упрощенный вариант)
|
||||
/// </summary>
|
||||
private (int StartIdx, int StartCol, int EndIdx, int EndCol) CalculateErrorPositionsSimple(
|
||||
TSqlFragment errorNode,
|
||||
List<(int LineNumber, string Text)> originalLines,
|
||||
List<(int LineNumber, string Text)> normalizedLines,
|
||||
int minIndent)
|
||||
{
|
||||
// Находим строки ошибки
|
||||
int errorStartLine = errorNode.StartLine;
|
||||
int errorEndLine = GetErrorEndLine(errorNode);
|
||||
|
||||
// Находим индексы в нормализованных строках
|
||||
int startIdx = -1;
|
||||
int endIdx = -1;
|
||||
|
||||
for (int i = 0; i < normalizedLines.Count; i++)
|
||||
{
|
||||
if (normalizedLines[i].LineNumber == errorStartLine)
|
||||
startIdx = i;
|
||||
if (normalizedLines[i].LineNumber == errorEndLine)
|
||||
endIdx = i;
|
||||
}
|
||||
|
||||
if (startIdx == -1) startIdx = 0;
|
||||
if (endIdx == -1) endIdx = normalizedLines.Count - 1;
|
||||
|
||||
// Вычисляем столбцы
|
||||
int startCol = CalculateAdjustedColumn(errorNode.StartColumn, minIndent);
|
||||
|
||||
int endCol;
|
||||
if (errorStartLine == errorEndLine)
|
||||
{
|
||||
endCol = CalculateAdjustedColumn(errorNode.StartColumn + errorNode.FragmentLength, minIndent);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorTokens = GetErrorTokens(errorNode);
|
||||
if (errorTokens.Any())
|
||||
{
|
||||
var lastToken = errorTokens.Last();
|
||||
endCol = CalculateAdjustedColumn(lastToken.Column + lastToken.Text.Length, minIndent);
|
||||
}
|
||||
else
|
||||
{
|
||||
endCol = startCol;
|
||||
}
|
||||
}
|
||||
|
||||
// Ограничиваем столбцы
|
||||
startCol = Math.Max(startCol, 1);
|
||||
endCol = Math.Max(endCol, 1);
|
||||
|
||||
return (startIdx, startCol, endIdx, endCol);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Корректирует столбец после удаления отступов
|
||||
/// </summary>
|
||||
private int CalculateAdjustedColumn(int originalColumn, int minIndent)
|
||||
{
|
||||
return Math.Max(originalColumn - minIndent, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает последнюю строку ошибки
|
||||
/// </summary>
|
||||
private int GetErrorEndLine(TSqlFragment errorNode)
|
||||
{
|
||||
var errorTokens = errorNode.ScriptTokenStream?
|
||||
.Where(t => t.Offset >= errorNode.StartOffset &&
|
||||
t.Offset < errorNode.StartOffset + errorNode.FragmentLength)
|
||||
.ToList();
|
||||
|
||||
return errorTokens?.LastOrDefault()?.Line ?? errorNode.StartLine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает токены ошибки
|
||||
/// </summary>
|
||||
private List<TSqlParserToken> GetErrorTokens(TSqlFragment errorNode)
|
||||
{
|
||||
return errorNode.ScriptTokenStream?
|
||||
.Where(t => t.Offset >= errorNode.StartOffset &&
|
||||
t.Offset < errorNode.StartOffset + errorNode.FragmentLength)
|
||||
.ToList() ?? new List<TSqlParserToken>();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,5 +4,5 @@ public interface IReporter : IBaseReporter
|
||||
{
|
||||
void ReportViolation(IRuleViolation violation);
|
||||
|
||||
void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string template, params string[] param);
|
||||
void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string template, BaseRuleVisitor.ExtractedBlock? Snippet, params string[] param);
|
||||
}
|
||||
@@ -13,5 +13,5 @@ public interface IRule
|
||||
int DynamicSqlStartLine { get; set; }
|
||||
|
||||
IEnumerable<Violation> Analyze(TSqlFragment fragment);
|
||||
|
||||
void SetParents(Dictionary<TSqlFragment, TSqlFragment?> parents);
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public interface IRuleViolation
|
||||
RuleViolationSeverity Severity { get; }
|
||||
|
||||
string Text { get; }
|
||||
BaseRuleVisitor.ExtractedBlock? Snippet { get; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SQLLinter.Common
|
||||
{
|
||||
public record Violation(string RuleName, string Template, int Line, int Column, string[] Params);
|
||||
public record Violation(string RuleName, string Template, int Line, int Column, BaseRuleVisitor.ExtractedBlock? Snippet, string[] Params);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ public class FragmentBuilder : IFragmentBuilder
|
||||
{
|
||||
foreach (var err in errors)
|
||||
{
|
||||
_reporter.ReportViolation(path, err.Line, err.Column, RuleViolationSeverity.Critical, "parse", err.Message);
|
||||
_reporter.ReportViolation(path, err.Line, err.Column, RuleViolationSeverity.Critical, "parse", err.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using SQLLinter.Common;
|
||||
using SQLLinter.Core.Interfaces;
|
||||
using SQLLinter.Infrastructure.Configuration.Overrides;
|
||||
using SQLLinter.Infrastructure.Interfaces;
|
||||
using SQLLinter.Infrastructure.Rules;
|
||||
using SQLLinter.Infrastructure.Rules.RuleExceptions;
|
||||
using SQLLinter.Infrastructure.Rules.RuleViolations;
|
||||
using System.Data;
|
||||
@@ -38,6 +39,8 @@ public class SqlRuleVisitor : IRuleVisitor
|
||||
|
||||
if (sqlFragment == null) return;
|
||||
|
||||
var parentMap = ParentMapBuilder.Build(sqlFragment);
|
||||
|
||||
var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray();
|
||||
if (errors.Any())
|
||||
{
|
||||
@@ -47,6 +50,7 @@ public class SqlRuleVisitor : IRuleVisitor
|
||||
var rules = _pluginHandler.Rules;
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
rule.SetParents(parentMap);
|
||||
VisitFragment(sqlFragment, rule, overrideArray, sqlPath);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +80,7 @@ public class SqlRuleVisitor : IRuleVisitor
|
||||
}
|
||||
}
|
||||
|
||||
violations.ForEach(t => _reporter.ReportViolation(filePath, t.Line, t.Column, rule.Severity, t.RuleName, t.Template, t.Params));
|
||||
violations.ForEach(t => _reporter.ReportViolation(filePath, t.Line, t.Column, rule.Severity, t.RuleName, t.Template, t.Snippet, t.Params));
|
||||
}
|
||||
|
||||
private static bool VisitorIsBlackListedForDynamicSql(IRule visitor)
|
||||
|
||||
@@ -1680,4 +1680,455 @@ svg .flowchart-link {
|
||||
.stat-value {
|
||||
font-size: var(--font-size-xxl);
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для детальных описаний нарушений */
|
||||
.violation-main-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.violation-main-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.description-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.detail-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-s);
|
||||
background: var(--surface-neutral);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-toggle-btn:hover {
|
||||
background: var(--surface-neutral-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-toggle-btn.active {
|
||||
background: var(--primary-light);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.detail-toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.detail-toggle-btn.active .detail-toggle-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.violation-detail-row {
|
||||
background: var(--surface-subtle);
|
||||
border-bottom: 2px solid var(--border-default);
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
padding: var(--spacing-l);
|
||||
background: var(--surface-default);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--border-radius-medium);
|
||||
margin: var(--spacing-s) var(--spacing-m);
|
||||
box-shadow: var(--shadow-4);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-l);
|
||||
padding-bottom: var(--spacing-s);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.detail-header h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-m);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--surface-neutral);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.detail-close-btn:hover {
|
||||
background: var(--surface-neutral-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
margin-bottom: var(--spacing-l);
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-content :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.detail-content :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin: var(--spacing-s) 0;
|
||||
}
|
||||
|
||||
.detail-content ul,
|
||||
.detail-content ol {
|
||||
margin: var(--spacing-s) 0;
|
||||
padding-left: var(--spacing-l);
|
||||
}
|
||||
|
||||
.detail-content li {
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.detail-content code {
|
||||
background: var(--surface-neutral);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.detail-content pre {
|
||||
background: var(--surface-neutral);
|
||||
padding: var(--spacing-m);
|
||||
border-radius: var(--border-radius-medium);
|
||||
overflow-x: auto;
|
||||
margin: var(--spacing-m) 0;
|
||||
}
|
||||
|
||||
.detail-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.detail-metadata {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-severity {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-severity.critical {
|
||||
background: var(--critical-bg);
|
||||
color: var(--critical-fg);
|
||||
}
|
||||
|
||||
.detail-severity.warning {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-fg);
|
||||
}
|
||||
|
||||
.detail-severity.info {
|
||||
background: var(--info-bg);
|
||||
color: var(--info-fg);
|
||||
}
|
||||
|
||||
.detail-location {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
/* Анимация появления деталей */
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.violation-detail-row[style*="table-row"] .detail-container {
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
/* Адаптивность для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.detail-container {
|
||||
margin: var(--spacing-xs);
|
||||
padding: var(--spacing-m);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
.detail-metadata {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.description-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
.detail-toggle-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Контейнер для блока кода */
|
||||
.code-block {
|
||||
position: relative;
|
||||
background: var(--surface-default);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--border-radius-medium);
|
||||
margin: var(--spacing-m) 0;
|
||||
overflow: hidden;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Контейнер для блока кода */
|
||||
.code-block {
|
||||
position: relative;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-medium);
|
||||
margin: var(--spacing-md) 0;
|
||||
overflow: hidden;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Контейнер для строк кода */
|
||||
.code-container {
|
||||
overflow-x: auto;
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
/* Строка кода */
|
||||
.code-line {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
padding: 0 var(--spacing-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.code-line:hover {
|
||||
background: var(--color-background-neutral);
|
||||
}
|
||||
|
||||
/* Номер строки */
|
||||
.line-number {
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
padding-right: var(--spacing-md);
|
||||
text-align: right;
|
||||
color: var(--color-text-tertiary);
|
||||
user-select: none;
|
||||
font-size: var(--font-size-xs);
|
||||
border-right: 1px solid var(--color-border);
|
||||
margin-right: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Подсветка ошибок */
|
||||
.code-line .critical {
|
||||
position: relative;
|
||||
color: var(--color-critical);
|
||||
background: var(--color-critical-bg);
|
||||
padding: 0 var(--spacing-xs);
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px solid var(--color-critical);
|
||||
}
|
||||
|
||||
.code-line .critical::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--color-critical) 50%, transparent 100%);
|
||||
}
|
||||
|
||||
.code-line .critical:hover {
|
||||
background: rgba(209, 52, 56, 0.2);
|
||||
}
|
||||
|
||||
/* Подсветка предупреждений */
|
||||
.code-line .warning {
|
||||
position: relative;
|
||||
color: var(--color-warning);
|
||||
background: var(--color-warning-bg);
|
||||
padding: 0 var(--spacing-xs);
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.code-line .warning:hover {
|
||||
background: rgba(255, 170, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Подсветка информационных сообщений */
|
||||
.code-line .info {
|
||||
position: relative;
|
||||
color: var(--color-info);
|
||||
background: var(--color-info-bg);
|
||||
padding: 0 var(--spacing-xs);
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px solid var(--color-info);
|
||||
}
|
||||
|
||||
.code-line .info:hover {
|
||||
background: rgba(0, 120, 212, 0.2);
|
||||
}
|
||||
|
||||
/* Подсветка успешных проверок */
|
||||
.code-line .success {
|
||||
position: relative;
|
||||
color: var(--color-success);
|
||||
background: var(--color-success-bg);
|
||||
padding: 0 var(--spacing-xs);
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px solid var(--color-success);
|
||||
}
|
||||
|
||||
.code-line .success:hover {
|
||||
background: rgba(16, 124, 16, 0.2);
|
||||
}
|
||||
|
||||
/* Строка с ошибкой */
|
||||
.code-line.error-line {
|
||||
background: linear-gradient(90deg, rgba(209, 52, 56, 0.05) 0%, rgba(209, 52, 56, 0.1) 100%);
|
||||
border-left: 3px solid var(--color-critical);
|
||||
}
|
||||
|
||||
/* Строка с предупреждением */
|
||||
.code-line.warning-line {
|
||||
background: linear-gradient(90deg, rgba(255, 170, 68, 0.05) 0%, rgba(255, 170, 68, 0.1) 100%);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
}
|
||||
|
||||
/* Строка с информацией */
|
||||
.code-line.info-line {
|
||||
background: linear-gradient(90deg, rgba(0, 120, 212, 0.05) 0%, rgba(0, 120, 212, 0.1) 100%);
|
||||
border-left: 3px solid var(--color-info);
|
||||
}
|
||||
|
||||
/* Темная тема */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.code-block {
|
||||
background: var(--color-background-alt);
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.code-line:hover {
|
||||
background: var(--color-background-neutral-dark);
|
||||
}
|
||||
|
||||
.line-number {
|
||||
color: var(--color-text-tertiary);
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.code-line .critical {
|
||||
background: var(--color-critical-bg);
|
||||
border-color: var(--color-critical);
|
||||
}
|
||||
|
||||
.code-line .warning {
|
||||
background: var(--color-warning-bg);
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.code-line .info {
|
||||
background: var(--color-info-bg);
|
||||
border-color: var(--color-info);
|
||||
}
|
||||
|
||||
.code-line .success {
|
||||
background: var(--color-success-bg);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.code-line.error-line {
|
||||
background: linear-gradient(90deg, rgba(209, 52, 56, 0.1) 0%, rgba(209, 52, 56, 0.15) 100%);
|
||||
}
|
||||
|
||||
.code-line.warning-line {
|
||||
background: linear-gradient(90deg, rgba(255, 170, 68, 0.1) 0%, rgba(255, 170, 68, 0.15) 100%);
|
||||
}
|
||||
|
||||
.code-line.info-line {
|
||||
background: linear-gradient(90deg, rgba(0, 120, 212, 0.1) 0%, rgba(0, 120, 212, 0.15) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.code-block {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.line-number {
|
||||
min-width: 30px;
|
||||
padding-right: var(--spacing-sm);
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.code-line {
|
||||
padding: 0 var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
@@ -291,29 +291,62 @@ class ReportRenderer {
|
||||
const section = document.createElement('div');
|
||||
section.className = `severity-section ${severity}`;
|
||||
|
||||
// Проверяем, есть ли хотя бы одно нарушение с деталями
|
||||
const hasDetails = violations.some(v => v.d || v.details);
|
||||
|
||||
const tableRows = violations.map(violation => {
|
||||
// Получаем правило по ID
|
||||
const ruleId = violation.r || violation.ruleId;
|
||||
const rule = this.rules[ruleId] || { n: 'Unknown', t: 'Описание отсутствует' };
|
||||
|
||||
// Формируем текст с подстановкой параметров
|
||||
// Получаем детальное описание
|
||||
const details = violation.d || violation.details || '';
|
||||
|
||||
// Формируем основной текст с подстановкой параметров
|
||||
let text = rule.t || '';
|
||||
const args = violation.a || violation.args || [];
|
||||
|
||||
if (args.length > 0 && text.includes('{')) {
|
||||
args.forEach((arg, index) => {
|
||||
text = text.replace(new RegExp(`\\{${index}\\}`, 'g'), arg);
|
||||
text = text.replace(new RegExp(`\\{${index}\\}`, 'g'), this.escapeHtml(arg));
|
||||
});
|
||||
}
|
||||
|
||||
// Уникальный ID для раскрывающейся секции
|
||||
const detailId = `detail_${severity}_${violation.i || violation.index}`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<tr class="violation-main-row" data-detail-id="${detailId}">
|
||||
<td class="index">${violation.i || violation.index}</td>
|
||||
<td class="line">${violation.l || violation.line}</td>
|
||||
<td class="column">${violation.c || violation.column}</td>
|
||||
<td class="rule">${this.escapeHtml(rule.n || rule.name)}</td>
|
||||
<td class="description">${this.escapeHtml(text)}</td>
|
||||
<td class="description">
|
||||
<div class="description-content">
|
||||
${this.escapeHtml(text)}
|
||||
${details ?
|
||||
`<button class="detail-toggle-btn" type="button" data-detail-id="${detailId}">
|
||||
<svg class="detail-toggle-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 9L12 16L5 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="detail-toggle-text">Детали</span>
|
||||
</button>` :
|
||||
''
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
${details ? `
|
||||
<tr class="violation-detail-row" id="${detailId}" style="display: none;">
|
||||
<td colspan="5" class="detail-cell">
|
||||
<div class="code-block">
|
||||
<div class="code-container">
|
||||
${details} <!-- HTML вставляется как есть, предполагается доверенный источник -->
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
@@ -325,7 +358,7 @@ class ReportRenderer {
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="${hasDetails ? 'has-details' : ''}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="index">#</th>
|
||||
@@ -342,9 +375,97 @@ class ReportRenderer {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Добавляем обработчики событий после создания секции
|
||||
this.addDetailHandlers(section);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
addDetailHandlers(section) {
|
||||
// Обработчик для кнопок раскрытия/закрытия деталей
|
||||
section.addEventListener('click', (e) => {
|
||||
const toggleBtn = e.target.closest('.detail-toggle-btn');
|
||||
const closeBtn = e.target.closest('.detail-close-btn');
|
||||
|
||||
if (toggleBtn) {
|
||||
const detailId = toggleBtn.dataset.detailId;
|
||||
this.toggleDetail(detailId, toggleBtn);
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
const detailId = closeBtn.dataset.detailId;
|
||||
this.hideDetail(detailId);
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие по нажатию Escape
|
||||
section.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const openDetails = section.querySelectorAll('.violation-detail-row[style="display: table-row;"]');
|
||||
openDetails.forEach(detailRow => {
|
||||
const detailId = detailRow.id;
|
||||
this.hideDetail(detailId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDetail(detailId, toggleBtn) {
|
||||
const detailRow = document.getElementById(detailId);
|
||||
const mainRow = toggleBtn.closest('.violation-main-row');
|
||||
|
||||
if (!detailRow || !mainRow) return;
|
||||
|
||||
const isVisible = detailRow.style.display === 'table-row';
|
||||
|
||||
if (isVisible) {
|
||||
this.hideDetail(detailId);
|
||||
} else {
|
||||
// Закрываем другие открытые детали в этой же таблице
|
||||
const table = detailRow.closest('table');
|
||||
if (table) {
|
||||
const otherDetails = table.querySelectorAll('.violation-detail-row[style="display: table-row;"]');
|
||||
otherDetails.forEach(row => {
|
||||
if (row.id !== detailId) {
|
||||
this.hideDetail(row.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Показываем текущую деталь
|
||||
detailRow.style.display = 'table-row';
|
||||
|
||||
// Обновляем состояние кнопки
|
||||
const icon = toggleBtn.querySelector('.detail-toggle-icon');
|
||||
if (icon) {
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
toggleBtn.classList.add('active');
|
||||
|
||||
// Прокручиваем к детали
|
||||
setTimeout(() => {
|
||||
detailRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
hideDetail(detailId) {
|
||||
const detailRow = document.getElementById(detailId);
|
||||
if (!detailRow) return;
|
||||
|
||||
detailRow.style.display = 'none';
|
||||
|
||||
// Обновляем кнопку
|
||||
const toggleBtn = document.querySelector(`.detail-toggle-btn[data-detail-id="${detailId}"]`);
|
||||
if (toggleBtn) {
|
||||
const icon = toggleBtn.querySelector('.detail-toggle-icon');
|
||||
if (icon) {
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
toggleBtn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
setupTabNavigation() {
|
||||
const tabs = this.tabsList.querySelectorAll('.tab');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using static SQLLinter.Common.BaseRuleVisitor;
|
||||
|
||||
namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v2;
|
||||
|
||||
@@ -119,6 +120,7 @@ public class HtmlReportFormatter : IReportFormatter
|
||||
Args = args,
|
||||
Column = violation.Column,
|
||||
Line = violation.Line,
|
||||
Details = violation.Snippet != null ? SnippetToHtml(violation.Snippet, violation.Severity) : null
|
||||
};
|
||||
|
||||
if (violation.Severity == RuleViolationSeverity.Critical)
|
||||
@@ -156,6 +158,110 @@ public class HtmlReportFormatter : IReportFormatter
|
||||
return reportData;
|
||||
}
|
||||
|
||||
public string SnippetToHtml(ExtractedBlock snippet, RuleViolationSeverity severity)
|
||||
{
|
||||
if (snippet == null || snippet.Lines.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
string errorSeverity = severity switch
|
||||
{
|
||||
RuleViolationSeverity.Critical => "critical",
|
||||
RuleViolationSeverity.Warning => "warning",
|
||||
RuleViolationSeverity.Info => "info",
|
||||
_ => "info"
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<div class=\"code-block\">");
|
||||
sb.AppendLine("<div class=\"code-container\">");
|
||||
|
||||
for (int i = 0; i < snippet.Lines.Count; i++)
|
||||
{
|
||||
var (lineNumber, text) = snippet.Lines[i];
|
||||
var relativeLineIndex = i + 1; // 1-based индекс в контексте блока
|
||||
|
||||
bool isErrorLine = relativeLineIndex >= snippet.ErrorStartLine &&
|
||||
relativeLineIndex <= snippet.ErrorEndLine;
|
||||
|
||||
string processedText = text;
|
||||
|
||||
if (isErrorLine)
|
||||
{
|
||||
if (relativeLineIndex == snippet.ErrorStartLine && relativeLineIndex == snippet.ErrorEndLine)
|
||||
{
|
||||
// Ошибка в пределах одной строки
|
||||
processedText = HighlightErrorInLine(
|
||||
text,
|
||||
snippet.ErrorStartColumn,
|
||||
snippet.ErrorEndColumn,
|
||||
errorSeverity);
|
||||
}
|
||||
else if (relativeLineIndex == snippet.ErrorStartLine)
|
||||
{
|
||||
// Первая строка многострочной ошибки
|
||||
processedText = HighlightErrorInLine(
|
||||
text,
|
||||
snippet.ErrorStartColumn,
|
||||
text.Length,
|
||||
errorSeverity);
|
||||
}
|
||||
else if (relativeLineIndex == snippet.ErrorEndLine)
|
||||
{
|
||||
// Последняя строка многострочной ошибки
|
||||
processedText = HighlightErrorInLine(
|
||||
text,
|
||||
1,
|
||||
snippet.ErrorEndColumn,
|
||||
errorSeverity);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Промежуточная строка многострочной ошибки
|
||||
processedText = $"<span class=\"{errorSeverity}\">{text}</span>";
|
||||
}
|
||||
}
|
||||
|
||||
var lineClass = "code-line";
|
||||
if (isErrorLine)
|
||||
{
|
||||
lineClass += " error-line";
|
||||
}
|
||||
|
||||
sb.Append($"<div class=\"{lineClass}\">");
|
||||
sb.Append($"<span class=\"line-number\">{lineNumber}</span> ");
|
||||
sb.Append(processedText);
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string HighlightErrorInLine(string line, int startColumn, int endColumn, string errorSeverity)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line) || startColumn > endColumn)
|
||||
return line;
|
||||
|
||||
// Корректируем индексы для 0-based string
|
||||
int startIdx = Math.Max(0, startColumn - 1);
|
||||
int endIdx = Math.Min(line.Length, endColumn - 1);
|
||||
|
||||
if (startIdx >= line.Length || endIdx <= 0 || startIdx >= endIdx)
|
||||
{
|
||||
// Если позиции вне диапазона, подсвечиваем всю строку
|
||||
return $"<span class=\"{errorSeverity}\">{line}</span>";
|
||||
}
|
||||
|
||||
string before = line.Substring(0, startIdx);
|
||||
string error = line.Substring(startIdx, endIdx - startIdx);
|
||||
string after = line.Substring(endIdx);
|
||||
|
||||
return $"{before}<span class=\"{errorSeverity}\">{error}</span>{after}";
|
||||
}
|
||||
|
||||
private void GenerateBeginningHtml(StringBuilder sb)
|
||||
{
|
||||
sb.AppendLine("""
|
||||
@@ -260,6 +366,9 @@ public class HtmlReportFormatter : IReportFormatter
|
||||
|
||||
[JsonPropertyName("a")] // args (optional)
|
||||
public List<string>? Args { get; set; }
|
||||
|
||||
[JsonPropertyName("d")] // details (optional)
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
private class Rule
|
||||
|
||||
@@ -41,7 +41,7 @@ public class Reporter : IReporter
|
||||
Report(violation.ToString());
|
||||
}
|
||||
|
||||
public void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string template, params string[] param)
|
||||
public void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string template, BaseRuleVisitor.ExtractedBlock? snippet, params string[] param)
|
||||
{
|
||||
ReportViolation(new RuleTemplateViolation()
|
||||
{
|
||||
@@ -52,6 +52,7 @@ public class Reporter : IReporter
|
||||
Column = column,
|
||||
Severity = severity,
|
||||
Params = param.ToList(),
|
||||
Snippet = snippet,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
71
SQLLinter/Infrastructure/Rules/ParentMapVisitor.cs
Normal file
71
SQLLinter/Infrastructure/Rules/ParentMapVisitor.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Microsoft.SqlServer.TransactSql.ScriptDom;
|
||||
|
||||
namespace SQLLinter.Infrastructure.Rules;
|
||||
|
||||
public static class ParentMapBuilder
|
||||
{
|
||||
public static Dictionary<TSqlFragment, TSqlFragment?> Build(TSqlFragment root)
|
||||
{
|
||||
var map = new Dictionary<TSqlFragment, TSqlFragment?>();
|
||||
Traverse(root, null, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void Traverse(
|
||||
TSqlFragment node,
|
||||
TSqlFragment? parent,
|
||||
Dictionary<TSqlFragment, TSqlFragment?> map)
|
||||
{
|
||||
if (!map.ContainsKey(node))
|
||||
map[node] = parent;
|
||||
|
||||
foreach (var child in node.GetChildren())
|
||||
{
|
||||
Traverse(child, node, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class ScriptDomExtensions
|
||||
{
|
||||
public static IEnumerable<TSqlFragment> GetChildren(this TSqlFragment node)
|
||||
{
|
||||
var collector = new DirectChildrenCollector(node);
|
||||
node.Accept(collector);
|
||||
return collector.Children;
|
||||
}
|
||||
|
||||
private class DirectChildrenCollector : TSqlFragmentVisitor
|
||||
{
|
||||
private readonly TSqlFragment _root;
|
||||
private bool _isRootVisited = false;
|
||||
|
||||
public List<TSqlFragment> Children { get; } = new();
|
||||
|
||||
public DirectChildrenCollector(TSqlFragment root)
|
||||
{
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public override void Visit(TSqlFragment fragment)
|
||||
{
|
||||
if (!_isRootVisited)
|
||||
{
|
||||
// Первый вызов — это сам root
|
||||
_isRootVisited = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Все остальные вызовы — это прямые дети root
|
||||
Children.Add(fragment);
|
||||
|
||||
// ВАЖНО: не спускаемся глубже
|
||||
return;
|
||||
}
|
||||
|
||||
// Продолжаем обход только для root
|
||||
base.Visit(fragment);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ namespace SQLLinter.Infrastructure.Rules.RuleViolations
|
||||
required public RuleViolationSeverity Severity { get; init; }
|
||||
|
||||
virtual public string Text { get; set; }
|
||||
public BaseRuleVisitor.ExtractedBlock? Snippet { get; set; }
|
||||
}
|
||||
|
||||
public class RuleTemplateViolation : RuleViolation
|
||||
|
||||
Reference in New Issue
Block a user