11 Commits

Author SHA1 Message Date
FrigaT
1efa6764d1 поправлены стили v2
All checks were successful
CI / build-test (push) Successful in 45s
Release / pack-and-publish (release) Successful in 46s
2025-12-29 02:30:11 +03:00
FrigaT
edc4d5c087 Поправлены правила. Исправлен параметр формирования деталировки
All checks were successful
CI / build-test (push) Successful in 31s
Release / pack-and-publish (release) Successful in 34s
2025-12-29 02:19:30 +03:00
FrigaT
507c466b5d Доработаны правила
All checks were successful
CI / build-test (push) Successful in 35s
Release / pack-and-publish (release) Successful in 34s
2025-12-29 01:40:25 +03:00
FrigaT
7fb11364c4 Изменено формирование деталировки: зависимость от строк, а не от родителя
All checks were successful
CI / build-test (push) Successful in 38s
Release / pack-and-publish (release) Successful in 35s
2025-12-29 01:19:51 +03:00
FrigaT
19c2357c04 из html v1 убарана минификация (возвращено к стоку)
All checks were successful
CI / build-test (push) Successful in 35s
Release / pack-and-publish (release) Successful in 31s
2025-12-28 23:33:34 +03:00
FrigaT
3c4eda7f57 Доработан js2
All checks were successful
CI / build-test (push) Successful in 40s
Release / pack-and-publish (release) Successful in 47s
2025-12-28 23:26:51 +03:00
FrigaT
119d94b0e8 изменен стиль v2 html 2025-12-28 23:25:51 +03:00
FrigaT
bf6c0b9229 Доработан js 2025-12-28 20:10:37 +03:00
FrigaT
0470309978 Добавлена настройка генерации деталировки
All checks were successful
CI / build-test (push) Successful in 50s
Release / pack-and-publish (release) Successful in 42s
2025-12-28 16:47:35 +03:00
FrigaT
cc7809871e Добавлено формирование детальной ошибки 2025-12-28 16:33:32 +03:00
FrigaT
e4acae11f0 добавлена оптимизированная версия нового дизайна 2025-12-28 12:56:59 +03:00
41 changed files with 9339 additions and 3380 deletions

View File

@@ -2,6 +2,7 @@
using SQLLinter.Infrastructure.Diagram; using SQLLinter.Infrastructure.Diagram;
using SQLLinter.Infrastructure.Parser; using SQLLinter.Infrastructure.Parser;
using SQLLinter.Infrastructure.Reporters; using SQLLinter.Infrastructure.Reporters;
using F = SQLLinter.Infrastructure.Reporters.Formatters.Html;
namespace SQLLinter.CLI namespace SQLLinter.CLI
{ {
@@ -42,7 +43,9 @@ namespace SQLLinter.CLI
["UpdateWhere"] = Common.RuleViolationSeverity.Critical, ["UpdateWhere"] = Common.RuleViolationSeverity.Critical,
["UpperLower"] = Common.RuleViolationSeverity.Critical, ["UpperLower"] = Common.RuleViolationSeverity.Critical,
["SetVariable"] = Common.RuleViolationSeverity.Critical, ["SetVariable"] = Common.RuleViolationSeverity.Critical,
} ["CreateProcedureInDbo"] = Common.RuleViolationSeverity.Warning,
},
GenerateDetails = false,
}; };
//var linter = new Linter(con, rep); //var linter = new Linter(con, rep);
@@ -56,22 +59,35 @@ namespace SQLLinter.CLI
using (StreamReader reader = new StreamReader(@"C:\Users\frost\Downloads\Telegram Desktop\test.sql")) using (StreamReader reader = new StreamReader(@"C:\Users\frost\Downloads\Telegram Desktop\test.sql"))
{ {
Dictionary<string, Stream> files = new Dictionary<string, Stream> var name = "test-qwewq-asdcxczc-asdsa -s--sadsasd-dsads-dsa-d-sd--dsa - 0.sql";
{
{ "test-qwewq-asdcxczc-asdsa -s--sadsasd-dsads-dsa-d-sd--dsa - 1.sql", reader.BaseStream }, Dictionary<string, Stream> files = new();
{ "test-qwewq-asdcxczc-asdsa -s--sadsasd-dsads-dsa-d-sd--dsa - 2.sql", reader.BaseStream },
for (int i = 0; i < 5; i++)
{
files[name + i + ".sql"] = reader.BaseStream;
}
};
linter.Run(files); linter.Run(files);
//diagramer.Run("test.sql", reader.BaseStream); diagramer.Run("test.sql", reader.BaseStream);
} }
//linter.Run(@"C:\Users\frost\Desktop\DISTR-2599\test.sql"); //linter.Run(@"C:\Users\frost\Desktop\DISTR-2599\test.sql");
var formatter = new HtmlReportFormatter_v2(); IReportFormatter formatter = new F.v3.HtmlReportFormatter();
var content = formatter.Format(rep.Violations, null); var content = formatter.Format(rep.Violations, bpmn);
File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test.html", content); File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test3.html", content);
formatter = new F.v2.HtmlReportFormatter();
content = formatter.Format(rep.Violations, bpmn);
File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test2.html", content);
formatter = new F.v1.HtmlReportFormatter();
content = formatter.Format(rep.Violations, bpmn);
File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test1.html", content);
} }
} }
} }

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"SQLLinter.CLI": {
"commandName": "Project",
"hotReloadEnabled": false
}
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.SqlServer.TransactSql.ScriptDom; using Microsoft.SqlServer.TransactSql.ScriptDom;
using System.Text;
namespace SQLLinter.Common; namespace SQLLinter.Common;
@@ -6,6 +7,15 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
{ {
protected readonly List<Violation> _violations = new(); protected readonly List<Violation> _violations = new();
protected Dictionary<TSqlFragment, TSqlFragment?>? _parents = null;
public void SetParents(Dictionary<TSqlFragment, TSqlFragment?>? parents)
=> _parents = parents;
protected TSqlFragment? GetParent(TSqlFragment node)
=> _parents is null ? null : _parents.TryGetValue(node, out var parent) ? parent : null;
public int DynamicSqlStartColumn { get; set; } public int DynamicSqlStartColumn { get; set; }
public int DynamicSqlStartLine { get; set; } public int DynamicSqlStartLine { get; set; }
public virtual string Name { get => GetDefaultRuleName(GetType().Name); } public virtual string Name { get => GetDefaultRuleName(GetType().Name); }
@@ -57,16 +67,327 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
protected void AddViolation(string RuleName, string Template, int Line, int Column, params string[] param) 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) 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) protected string GetText(params string[] param)
{ {
return string.Format(this.Text, param); return string.Format(this.Text, param);
} }
private TSqlFragment? FindContextBlock(TSqlFragment node)
{
if (_parents == null) return null;
return 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;
// DML
case InsertStatement:
case UpdateStatement:
case DeleteStatement:
case MergeStatement:
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 || errorNode.ScriptTokenStream == null)
return null;
var endLine = errorNode.ScriptTokenStream.Where(t => t.Offset < node.StartOffset + node.FragmentLength).Max(t => t.Line) + 2;
// 1. Получаем токены для блока
var tokens = errorNode.ScriptTokenStream
.Where(t => t.Line >= node.StartLine - 2 &&
t.Line <= endLine)
.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>();
}
} }

View File

@@ -4,5 +4,5 @@ public interface IReporter : IBaseReporter
{ {
void ReportViolation(IRuleViolation violation); 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);
} }

View File

@@ -14,4 +14,5 @@ public interface IRule
IEnumerable<Violation> Analyze(TSqlFragment fragment); IEnumerable<Violation> Analyze(TSqlFragment fragment);
void SetParents(Dictionary<TSqlFragment, TSqlFragment?>? parents);
} }

View File

@@ -13,4 +13,5 @@ public interface IRuleViolation
RuleViolationSeverity Severity { get; } RuleViolationSeverity Severity { get; }
string Text { get; } string Text { get; }
BaseRuleVisitor.ExtractedBlock? Snippet { get; }
} }

View File

@@ -1,4 +1,4 @@
namespace SQLLinter.Common 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);
} }

View File

@@ -20,5 +20,10 @@ namespace SQLLinter.Core.Interfaces
/// Список сторонних плагинов. /// Список сторонних плагинов.
/// </summary> /// </summary>
List<string> Plugins { get; set; } List<string> Plugins { get; set; }
/// <summary>
/// Генерировать деталировку ошибки.
/// </summary>
bool GenerateDetails { get; set; }
} }
} }

View File

@@ -4,5 +4,5 @@ namespace SQLLinter.Core.Interfaces;
public interface IRuleVisitor public interface IRuleVisitor
{ {
void VisitRules(string path, IEnumerable<IRuleException> igoredRules, Stream sqlFileStream); void VisitRules(string path, IEnumerable<IRuleException> igoredRules, Stream sqlFileStream, bool generateDetails);
} }

View File

@@ -8,5 +8,6 @@ namespace SQLLinter.Infrastructure.Configuration
public int CompatibilityLevel { get; set; } public int CompatibilityLevel { get; set; }
public Dictionary<string, RuleViolationSeverity> Rules { get; set; } = new(); public Dictionary<string, RuleViolationSeverity> Rules { get; set; } = new();
public List<string> Plugins { get; set; } = new(); public List<string> Plugins { get; set; } = new();
public bool GenerateDetails { get; set; } = false;
} }
} }

View File

@@ -25,7 +25,6 @@ public static class BpmnBuilder
{ {
var visitor = new BpmnVisitor(diagram); var visitor = new BpmnVisitor(diagram);
fragment.Accept(visitor); fragment.Accept(visitor);
visitor.Diagram.AddMissingProcessEdges();
return visitor.Diagram; return visitor.Diagram;
} }

View File

@@ -58,6 +58,7 @@ public class SqlDiagramProcessor : ISqlDiagramProcessor
} }
BpmnBuilder.Build(fragment, _bpmnDiagram); BpmnBuilder.Build(fragment, _bpmnDiagram);
_bpmnDiagram.AddMissingProcessEdges();
} }
private Stream GetFileContents(string filePath) private Stream GetFileContents(string filePath)

View File

@@ -55,7 +55,7 @@ public class FragmentBuilder : IFragmentBuilder
{ {
foreach (var err in errors) 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);
} }
} }

View File

@@ -7,14 +7,16 @@ namespace SQLLinter.Infrastructure.Parser;
public class SqlFileProcessor : ISqlFileProcessor public class SqlFileProcessor : ISqlFileProcessor
{ {
private readonly IRuleVisitor ruleVisitor; private readonly IRuleVisitor ruleVisitor;
private readonly IConfig _config;
private readonly IRuleExceptionFinder ruleExceptionFinder; private readonly IRuleExceptionFinder ruleExceptionFinder;
public SqlFileProcessor( public SqlFileProcessor(IConfig config,
IRuleVisitor ruleVisitor, IRuleVisitor ruleVisitor,
IPluginHandler pluginHandler IPluginHandler pluginHandler
) )
{ {
this._config = config;
this.ruleVisitor = ruleVisitor; this.ruleVisitor = ruleVisitor;
ruleExceptionFinder = new RuleExceptionFinder(pluginHandler.RuleWithNames); ruleExceptionFinder = new RuleExceptionFinder(pluginHandler.RuleWithNames);
} }
@@ -93,7 +95,7 @@ public class SqlFileProcessor : ISqlFileProcessor
private void ProcessRules(Stream fileStream, IEnumerable<IRuleException> ignoredRules, string filePath) private void ProcessRules(Stream fileStream, IEnumerable<IRuleException> ignoredRules, string filePath)
{ {
ruleVisitor.VisitRules(filePath, ignoredRules, fileStream); ruleVisitor.VisitRules(filePath, ignoredRules, fileStream, this._config.GenerateDetails);
} }
private Stream GetFileContents(string filePath) private Stream GetFileContents(string filePath)

View File

@@ -29,7 +29,7 @@ public class SqlRuleVisitor : IRuleVisitor
this._sqlStreamReaderBuilder = sqlStreamReaderBuilder; this._sqlStreamReaderBuilder = sqlStreamReaderBuilder;
} }
public void VisitRules(string sqlPath, IEnumerable<IRuleException> ignoredRules, Stream sqlFileStream) public void VisitRules(string sqlPath, IEnumerable<IRuleException> ignoredRules, Stream sqlFileStream, bool generateDetails)
{ {
var overrides = _overrideFinder.GetOverrideList(sqlFileStream); var overrides = _overrideFinder.GetOverrideList(sqlFileStream);
var overrideArray = overrides as IOverride[] ?? overrides.ToArray(); var overrideArray = overrides as IOverride[] ?? overrides.ToArray();
@@ -38,6 +38,9 @@ public class SqlRuleVisitor : IRuleVisitor
if (sqlFragment == null) return; if (sqlFragment == null) return;
//Dictionary<TSqlFragment, TSqlFragment?>? parentMap = generateDetails ? ParentMapBuilder.Build(sqlFragment) : null;
Dictionary<TSqlFragment, TSqlFragment?>? parentMap = generateDetails ? new Dictionary<TSqlFragment, TSqlFragment?>() : null;
var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray(); var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray();
if (errors.Any()) if (errors.Any())
{ {
@@ -47,6 +50,7 @@ public class SqlRuleVisitor : IRuleVisitor
var rules = _pluginHandler.Rules; var rules = _pluginHandler.Rules;
foreach (var rule in rules) foreach (var rule in rules)
{ {
rule.SetParents(parentMap);
VisitFragment(sqlFragment, rule, overrideArray, sqlPath); 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) private static bool VisitorIsBlackListedForDynamicSql(IRule visitor)

View File

@@ -2,9 +2,9 @@
using SQLLinter.Infrastructure.Diagram; using SQLLinter.Infrastructure.Diagram;
using System.Text; using System.Text;
namespace SQLLinter.Infrastructure.Reporters; namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v1;
public class HtmlReportFormatter_v1 : IReportFormatter public class HtmlReportFormatter : IReportFormatter
{ {
public string Format(List<IRuleViolation> violations) public string Format(List<IRuleViolation> violations)
=> Format(violations, null); => Format(violations, null);
@@ -143,6 +143,6 @@ public class HtmlReportFormatter_v1 : IReportFormatter
sb.AppendLine("</body>"); sb.AppendLine("</body>");
sb.AppendLine("</html>"); sb.AppendLine("</html>");
return HtmlMinifier.MinifyHtml(sb.ToString()); return sb.ToString();
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,391 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
using SQLLinter.Infrastructure.Rules.RuleViolations;
using System.Data;
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;
public class HtmlReportFormatter : IReportFormatter
{
public string Format(List<IRuleViolation> violations)
=> Format(violations, null);
public string Format(List<IRuleViolation> violations, BpmnDiagram? diagram)
{
var sb = new StringBuilder();
GenerateBeginningHtml(sb);
// Подготовка данных для передачи в JS
var reportData = PrepareReportData(violations, diagram);
var jsonData = JsonSerializer.Serialize(reportData, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
if (violations.Count == 0 && diagram == null)
{
// Случай без нарушений
sb.AppendLine("<div class=\"no-violations\">");
sb.AppendLine("<div class=\"no-violations-content\">");
sb.AppendLine("<div class=\"no-violations-icon\">✅</div>");
sb.AppendLine("<h3 class=\"no-violations-title\">Проверка завершена</h3>");
sb.AppendLine("<p class=\"no-violations-description\">Нарушений правил SQL не обнаружено.</p>");
sb.AppendLine("</div>");
sb.AppendLine("</div>");
GenerateEndingHtml(sb, false, HtmlMinifier.CompressJson(jsonData));
return sb.ToString();
}
// Основной контейнер для отчета
sb.AppendLine("""
<div id="reports-container"></div>
<div id="tabs-container" class="tabs-container">
<div class="tabs" id="tabs-list"></div>
</div>
""");
GenerateEndingHtml(sb, diagram != null, jsonData);
var html = HtmlMinifier.MinifyHtml(sb.ToString());
return html;
}
private ReportData PrepareReportData(List<IRuleViolation> violations, BpmnDiagram? diagram)
{
var reportData = new ReportData();
// Группировка по файлам
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key)
.ToList();
foreach (var fileGroup in groupedByFile)
{
var fileData = new FileReport
{
Name = fileGroup.Key,
};
// Группировка по severity
var severityGroups = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key)
.ToList();
foreach (var violation in fileGroup.Select(t => t).OrderBy(v => v.Line).ThenBy(v => v.Column))
{
int ruleId;
List<string> args = new();
if (violation is RuleTemplateViolation templateRule)
{
args = templateRule.Params.Select(p => EscapeHtml(p)).ToList();
if (reportData.Rules.Any(t => t.Value.Name == templateRule.RuleName))
{
ruleId = reportData.Rules.First(t => t.Value.Name == templateRule.RuleName).Key;
}
else
{
ruleId = reportData.Rules.Count + 1;
reportData.Rules.Add(ruleId, new Rule
{
Name = templateRule.RuleName,
Template = templateRule.RuleTemplate
});
}
}
else
{
ruleId = reportData.Rules.Count + 1;
reportData.Rules.Add(ruleId, new Rule
{
Name = violation.RuleName,
Template = violation.Text,
});
}
var v = new Violation()
{
RuleId = ruleId,
Args = args,
Column = violation.Column,
Line = violation.Line,
Details = violation.Snippet != null ? SnippetToHtml(violation.Snippet, violation.Severity) : null
};
if (violation.Severity == RuleViolationSeverity.Critical)
{
v.Index = fileData.Violations.Critical.Count + 1;
fileData.Violations.Critical.Add(v);
}
else if (violation.Severity == RuleViolationSeverity.Warning)
{
v.Index = fileData.Violations.Warning.Count + 1;
fileData.Violations.Warning.Add(v);
}
else if (violation.Severity == RuleViolationSeverity.Info)
{
v.Index = fileData.Violations.Info.Count + 1;
fileData.Violations.Info.Add(v);
}
}
reportData.Files.Add(fileData);
}
// Добавление диаграммы, если есть
if (diagram != null)
{
reportData.Diagram = new Diagram
{
Content = MermaidRenderer.ToMermaidContent(diagram),
HasDiagram = true
};
}
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 += $" {errorSeverity}-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("""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт по SQLпроверкам</title>
<style>
""");
sb.AppendLine(LoadResource("HtmlFormatter_v2.css"));
sb.AppendLine("</style></head><body><main id=\"main-content\">");
}
private void GenerateEndingHtml(StringBuilder sb, bool hasDiagram, string jsonData)
{
// Вставка JSON данных
sb.AppendLine($"""
<script id="report-data" type="application/json">
{jsonData}
</script>
""");
sb.AppendLine("""
<script type="module">
""");
// Загружаем основной JS
sb.AppendLine(LoadResource("HtmlFormatter_v2.js"));
sb.AppendLine("""
</script>
</body>
</html>
""");
}
private static string LoadResource(string endsWith)
{
var assembly = Assembly.GetExecutingAssembly();
var name = assembly.GetManifestResourceNames()
.First(n => n.EndsWith(endsWith, StringComparison.OrdinalIgnoreCase));
using var stream = assembly.GetManifestResourceStream(name);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static string EscapeHtml(string text)
{
return System.Net.WebUtility.HtmlEncode(text);
}
// Классы для сериализации
private class ReportData
{
[JsonPropertyName("f")] // files
public List<FileReport> Files { get; set; } = new();
[JsonPropertyName("r")] // rules
public Dictionary<int, Rule> Rules { get; set; } = new();
[JsonPropertyName("d")] // diagram
public Diagram Diagram { get; set; } = new();
}
private class FileReport
{
[JsonPropertyName("n")] // name
public string Name { get; set; } = string.Empty;
[JsonPropertyName("v")] // violations
public Violations Violations { get; set; } = new();
}
private class Violations
{
[JsonPropertyName("c")] // critical
public List<Violation> Critical { get; set; } = new();
[JsonPropertyName("w")] // warning
public List<Violation> Warning { get; set; } = new();
[JsonPropertyName("i")] // info
public List<Violation> Info { get; set; } = new();
}
private class Violation
{
[JsonPropertyName("i")] // index
public int Index { get; set; }
[JsonPropertyName("l")] // line
public int Line { get; set; }
[JsonPropertyName("c")] // column
public int Column { get; set; }
[JsonPropertyName("r")] // ruleId
public int RuleId { get; set; }
[JsonPropertyName("a")] // args (optional)
public List<string>? Args { get; set; }
[JsonPropertyName("d")] // details (optional)
public string? Details { get; set; }
}
private class Rule
{
[JsonPropertyName("n")] // name
public string Name { get; set; } = string.Empty;
[JsonPropertyName("t")] // template
public string Template { get; set; } = string.Empty;
}
private class Diagram
{
[JsonPropertyName("c")] // content
public string Content { get; set; } = string.Empty;
[JsonPropertyName("h")] // hasDiagram
public bool HasDiagram { get; set; }
}
}

View File

@@ -0,0 +1,542 @@
/* ---------------------------------------------------------
FLUENT UI 2 — BASE VARIABLES
--------------------------------------------------------- */
:root {
--color-primary: #0f6cbd;
--color-primary-hover: #115ea3;
--color-primary-light: #d0e7ff;
--color-bg: #ffffff;
--color-bg-alt: #f5f5f5;
--color-bg-neutral: #f3f2f1;
--color-border: #d1d1d1;
--color-border-strong: #b5b5b5;
--color-text: #1a1a1a;
--color-text-secondary: #5e5e5e;
--color-text-tertiary: #8a8a8a;
--color-critical: #d13438;
--color-critical-bg: #f8d7da;
--color-warning: #ffaa44;
--color-warning-bg: #fff4ce;
--color-info: #0f6cbd;
--color-info-bg: #d0e7ff;
--radius-s: 4px;
--radius-m: 6px;
--radius-l: 8px;
--space-xs: 4px;
--space-s: 8px;
--space-m: 12px;
--space-l: 16px;
--space-xl: 20px;
--space-xxl: 28px;
--font-s: 12px;
--font-m: 14px;
--font-l: 16px;
--font-xl: 18px;
--z-tabs: 1000;
--z-header: 900;
}
/* ---------------------------------------------------------
RESET
--------------------------------------------------------- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", sans-serif;
background: var(--color-bg-neutral);
color: var(--color-text);
line-height: 1.5;
}
/* ---------------------------------------------------------
FILE REPORT WRAPPER
--------------------------------------------------------- */
.file-report {
display: none;
padding-bottom: 120px;
}
.file-report.active {
display: block;
}
/* ---------------------------------------------------------
FILE TITLE (STICKY)
--------------------------------------------------------- */
.file-title-container {
position: sticky;
top: 0;
z-index: var(--z-header);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
padding: var(--space-l) var(--space-xl);
}
.file-title {
font-size: var(--font-xl);
font-weight: 600;
color: var(--color-text);
}
/* ---------------------------------------------------------
SEVERITY SECTIONS (FLUENT UI 2 STYLE)
--------------------------------------------------------- */
.severity-section {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-l);
padding: var(--space-xl);
margin: var(--space-xl) var(--space-xl);
}
.severity-section.critical {
border-left: 4px solid var(--color-critical);
}
.severity-section.warning {
border-left: 4px solid var(--color-warning);
}
.severity-section.info {
border-left: 4px solid var(--color-info);
}
.severity-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-l);
padding-bottom: var(--space-s);
border-bottom: 1px solid var(--color-border);
}
.severity-title h2 {
font-size: var(--font-l);
font-weight: 600;
}
.severity-count {
background: var(--color-bg-alt);
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-s);
font-size: var(--font-s);
color: var(--color-text-secondary);
}
/* ---------------------------------------------------------
GRID TABLE (REPLACES <table>)
--------------------------------------------------------- */
.grid-table {
display: grid;
gap: var(--space-xs);
}
.grid-header,
.grid-row {
display: grid;
grid-template-columns: 40px 60px 60px 250px 1fr;
gap: var(--space-xs);
padding: var(--space-s);
border-radius: var(--radius-s);
}
.grid-header {
background: var(--color-bg-alt);
font-weight: 600;
color: var(--color-text-secondary);
font-size: var(--font-s);
}
.grid-row {
background: var(--color-bg);
border: 1px solid var(--color-border);
font-size: var(--font-m);
transition: background-color 0.15s ease;
}
.grid-row:hover {
background: var(--color-bg-alt);
}
/* ---------------------------------------------------------
SUMMARY SECTION
--------------------------------------------------------- */
.summary-section {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-l);
padding: var(--space-xl);
margin: var(--space-xl);
}
.summary-header h2 {
font-size: var(--font-l);
margin-bottom: var(--space-m);
}
.summary-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-m);
margin-bottom: var(--space-l);
}
.stat-card {
background: var(--color-bg-alt);
border-radius: var(--radius-m);
padding: var(--space-m);
text-align: center;
}
.stat-value {
font-size: var(--font-xl);
font-weight: 600;
}
.stat-label {
font-size: var(--font-s);
color: var(--color-text-secondary);
}
/* Progress bar */
.progress-bar {
display: flex;
height: 8px;
border-radius: var(--radius-s);
overflow: hidden;
background: var(--color-border);
}
.progress-fill.critical {
background: var(--color-critical);
}
.progress-fill.warning {
background: var(--color-warning);
}
.progress-fill.info {
background: var(--color-info);
}
/* ---------------------------------------------------------
FILE CARDS IN SUMMARY
--------------------------------------------------------- */
.files-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-m);
}
.file-card {
background: var(--color-bg-alt);
border-radius: var(--radius-m);
padding: var(--space-m);
transition: background-color 0.2s ease, transform 0.2s ease;
}
.file-card:hover {
transform: translateY(-2px);
background: var(--color-bg-neutral);
}
.file-name-small {
font-weight: 600;
font-size: var(--font-m);
}
.file-card-bottom {
display: flex;
justify-content: space-between;
margin-top: var(--space-s);
}
.file-violations {
display: flex;
gap: var(--space-xs);
}
.violation-badge {
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-s);
font-size: var(--font-s);
color: var(--color-text);
}
.violation-badge.critical {
background: var(--color-critical-bg);
color: var(--color-critical);
}
.violation-badge.warning {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.violation-badge.info {
background: var(--color-info-bg);
color: var(--color-info);
}
/* ---------------------------------------------------------
TABS CONTAINER (STICKY BOTTOM)
--------------------------------------------------------- */
.tabs-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-bg);
border-top: 1px solid var(--color-border);
padding: var(--space-s) var(--space-l);
z-index: var(--z-tabs);
}
/* ---------------------------------------------------------
TABS — HORIZONTAL SCROLL + VISIBLE SCROLLBAR
--------------------------------------------------------- */
.tabs {
display: flex;
flex-wrap: nowrap;
gap: var(--space-s);
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scroll-behavior: smooth;
padding-bottom: 4px;
/* Firefox scrollbar */
scrollbar-width: thin;
scrollbar-color: var(--color-border-strong) var(--color-bg-alt);
}
/* Chrome / Edge / Safari scrollbar */
.tabs::-webkit-scrollbar {
height: 8px;
}
.tabs::-webkit-scrollbar-track {
background: var(--color-bg-alt);
border-radius: 4px;
}
.tabs::-webkit-scrollbar-thumb {
background: var(--color-border-strong);
border-radius: 4px;
}
.tabs::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* ---------------------------------------------------------
TAB BASE STYLES (COMPACT, AUTO WIDTH, MAX 2 LINES)
--------------------------------------------------------- */
.tab {
flex: 0 0 auto; /* растягивается под контент, не ломая одну линию */
min-width: 0;
padding: 6px 12px;
line-height: 1.25;
display: flex;
align-items: flex-start;
background: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-m);
cursor: pointer;
font-size: var(--font-m);
font-weight: 500;
color: var(--color-text-secondary);
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.tab:hover {
background: var(--color-bg-neutral);
transform: translateY(-1px);
}
.tab.active {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
transform: translateY(-2px);
}
/* Внутренний layout таба */
.tab-inner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
width: 100%;
}
/* Текст таба — максимум 2 строки, с переносами */
.tab-text {
font-size: 13px;
line-height: 1.25;
white-space: normal;
word-break: break-word;
overflow-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-height: calc(1.25em * 2);
overflow: hidden;
}
/* Счётчики — компактные */
.tab-counters {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.tab-counter {
font-size: 10px;
min-width: 14px;
height: 14px;
padding: 0 3px;
line-height: 14px;
border-radius: var(--radius-s);
text-align: center;
}
.tab-counter.critical {
background: var(--color-critical-bg);
color: var(--color-critical);
}
.tab-counter.warning {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.tab-counter.info {
background: var(--color-info-bg);
color: var(--color-info);
}
.tab-counter.empty {
opacity: 0.3;
}
/* Мобильная компактность */
@media (max-width: 480px) {
.tab {
padding: 4px 8px;
}
.tab-text {
font-size: 12px;
max-height: calc(1.2em * 2);
-webkit-line-clamp: 2;
}
.tab-counter {
min-width: 12px;
height: 12px;
font-size: 9px;
padding: 0 2px;
}
}
/* ---------------------------------------------------------
FLUENT UI 2 — DARK THEME (PREFERS-COLOR-SCHEME)
--------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1f1f1f;
--color-bg-alt: #2a2a2a;
--color-bg-neutral: #2d2d2d;
--color-text: #f3f3f3;
--color-text-secondary: #c8c8c8;
--color-text-tertiary: #9a9a9a;
--color-border: #3a3a3a;
--color-border-strong: #4a4a4a;
--color-primary: #3aa0f3;
--color-primary-hover: #2899f5;
--color-primary-light: #0f2b4d;
--color-critical-bg: #4c191b;
--color-warning-bg: #4c3b1a;
--color-info-bg: #0f2b4d;
}
.file-title-container {
background: var(--color-bg);
}
.severity-section {
background: var(--color-bg);
border-color: var(--color-border);
}
.grid-row {
background: var(--color-bg-alt);
border-color: var(--color-border);
}
.grid-row:hover {
background: var(--color-bg-neutral);
}
.summary-section {
background: var(--color-bg);
border-color: var(--color-border);
}
.file-card {
background: var(--color-bg-alt);
}
.tabs-container {
background: var(--color-bg);
border-color: var(--color-border);
}
.tab {
background: var(--color-bg-alt);
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.tab.active {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Dark scrollbar colors */
.tabs {
scrollbar-color: var(--color-border-strong) var(--color-bg);
}
.tabs::-webkit-scrollbar-track {
background: var(--color-bg);
}
.tabs::-webkit-scrollbar-thumb {
background: var(--color-border-strong);
}
.tabs::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
}
/* ---------------------------------------------------------
TAB TRANSITIONS & FILE REPORT ANIMATION
--------------------------------------------------------- */
.file-report {
opacity: 0;
transform: translateY(6px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.file-report.active {
opacity: 1;
transform: translateY(0);
}

View File

@@ -0,0 +1,486 @@
document.addEventListener("DOMContentLoaded", function () {
const dataEl = document.getElementById("report-data");
if (!dataEl) return console.error("report-data not found");
const data = JSON.parse(dataEl.textContent || "{}");
const files = data.files || data.f || [];
const rules = data.rules || data.r || {};
const tabsList = document.getElementById("tabs-list");
const reportsContainer = document.getElementById("reports-container");
if (!tabsList || !reportsContainer) {
console.error("Missing tabs-list or reports-container");
return;
}
/* ---------------------------------------------------------
UTILS
--------------------------------------------------------- */
function escapeHtml(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function countViolations(v) {
return {
c: v?.c?.length || 0,
w: v?.w?.length || 0,
i: v?.i?.length || 0
};
}
const rendered = new Set(); // ленивый рендер
/* ---------------------------------------------------------
RENDER TABS
--------------------------------------------------------- */
function renderTabs() {
const frag = document.createDocumentFragment();
files.forEach((file, index) => {
const v = countViolations(file.v);
const btn = document.createElement("button");
btn.className = "tab";
btn.dataset.target = `file_${index}`;
btn.dataset.index = index;
const inner = document.createElement("div");
inner.className = "tab-inner";
const text = document.createElement("span");
text.className = "tab-text";
text.title = file.n;
text.textContent = file.n;
const counters = document.createElement("div");
counters.className = "tab-counters";
const c1 = document.createElement("span");
c1.className = "tab-counter critical" + (v.c ? "" : " empty");
c1.textContent = v.c || "";
const c2 = document.createElement("span");
c2.className = "tab-counter warning" + (v.w ? "" : " empty");
c2.textContent = v.w || "";
const c3 = document.createElement("span");
c3.className = "tab-counter info" + (v.i ? "" : " empty");
c3.textContent = v.i || "";
counters.append(c1, c2, c3);
inner.append(text, counters);
btn.append(inner);
frag.append(btn);
});
// Summary tab
const summaryBtn = document.createElement("button");
summaryBtn.className = "tab";
summaryBtn.dataset.target = "summary_report";
const inner = document.createElement("div");
inner.className = "tab-inner";
const text = document.createElement("span");
text.className = "tab-text";
text.textContent = "Summary";
inner.append(text);
summaryBtn.append(inner);
frag.append(summaryBtn);
tabsList.innerHTML = "";
tabsList.append(frag);
}
/* ---------------------------------------------------------
GRID ROW CREATOR (VIOLATIONS)
--------------------------------------------------------- */
function createGridRow(v, rule) {
const row = document.createElement("div");
row.className = "grid-row";
const cIndex = document.createElement("div");
cIndex.textContent = v.i;
const cLine = document.createElement("div");
cLine.textContent = v.l;
const cCol = document.createElement("div");
cCol.textContent = v.c;
const cRule = document.createElement("div");
cRule.textContent = rule.n;
let text = rule.t || "";
const args = v.a || v.args;
if (Array.isArray(args)) {
for (let i = 0; i < args.length; i++) {
text = text.replace(`{${i}}`, args[i]);
}
}
const cDesc = document.createElement("div");
cDesc.textContent = text;
row.append(cIndex, cLine, cCol, cRule, cDesc);
return row;
}
/* ---------------------------------------------------------
FILE REPORT
--------------------------------------------------------- */
function renderFileReport(index) {
const file = files[index];
const v = file.v || {};
const root = document.createElement("div");
// Title
const titleWrap = document.createElement("div");
titleWrap.className = "file-title-container";
const title = document.createElement("div");
title.className = "file-title";
const name = document.createElement("span");
name.className = "file-name";
name.textContent = file.n;
title.append(name);
titleWrap.append(title);
root.append(titleWrap);
// Sections
function addSection(label, list, cls) {
if (!Array.isArray(list) || list.length === 0) return;
const section = document.createElement("div");
section.className = `severity-section ${cls}`;
const header = document.createElement("div");
header.className = "severity-header";
const hTitle = document.createElement("div");
hTitle.className = "severity-title";
const h2 = document.createElement("h2");
h2.textContent = label;
const count = document.createElement("span");
count.className = "severity-count";
count.textContent = list.length;
hTitle.append(h2, count);
header.append(hTitle);
section.append(header);
const grid = document.createElement("div");
grid.className = "grid-table";
// header row
const headerRow = document.createElement("div");
headerRow.className = "grid-header";
["#", "Строка", "Колонка", "Правило", "Описание"].forEach(t => {
const cell = document.createElement("div");
cell.textContent = t;
headerRow.append(cell);
});
grid.append(headerRow);
// rows
for (let i = 0; i < list.length; i++) {
const vItem = list[i];
const rule = rules[vItem.r] || { n: "Unknown", t: "Описание отсутствует" };
grid.append(createGridRow(vItem, rule));
}
section.append(grid);
root.append(section);
}
addSection("Critical", v.c, "critical");
addSection("Warning", v.w, "warning");
addSection("Info", v.i, "info");
return root;
}
/* ---------------------------------------------------------
SUMMARY
--------------------------------------------------------- */
function renderSummary() {
let totalC = 0, totalW = 0, totalI = 0;
for (let i = 0; i < files.length; i++) {
const v = countViolations(files[i].v);
totalC += v.c;
totalW += v.w;
totalI += v.i;
}
const total = totalC + totalW + totalI || 1;
const root = document.createElement("div");
// Title
const titleWrap = document.createElement("div");
titleWrap.className = "file-title-container";
const title = document.createElement("div");
title.className = "file-title";
const name = document.createElement("span");
name.className = "file-name";
name.textContent = "Сводный отчет";
title.append(name);
titleWrap.append(title);
root.append(titleWrap);
// Stats
const section = document.createElement("div");
section.className = "summary-section";
const header = document.createElement("div");
header.className = "summary-header";
const hTitle = document.createElement("div");
hTitle.className = "summary-title";
const h2 = document.createElement("h2");
h2.textContent = "Общая статистика";
hTitle.append(h2);
header.append(hTitle);
section.append(header);
const grid = document.createElement("div");
grid.className = "summary-stats-grid";
function statCard(cls, value, label) {
const card = document.createElement("div");
card.className = `stat-card ${cls}`;
const v = document.createElement("div");
v.className = "stat-value";
v.textContent = value;
const l = document.createElement("div");
l.className = "stat-label";
l.textContent = label;
card.append(v, l);
return card;
}
grid.append(
statCard("success", files.length, "Всего файлов"),
statCard("critical", totalC, "Critical нарушений"),
statCard("warning", totalW, "Warning нарушений"),
statCard("info", totalI, "Info нарушений")
);
section.append(grid);
// Progress
const dist = document.createElement("div");
dist.className = "violation-distribution";
const bar = document.createElement("div");
bar.className = "progress-bar";
const pc = (totalC / total) * 100;
const pw = (totalW / total) * 100;
const pi = (totalI / total) * 100;
const f1 = document.createElement("div");
f1.className = "progress-fill critical";
f1.style.width = pc + "%";
const f2 = document.createElement("div");
f2.className = "progress-fill warning";
f2.style.width = pw + "%";
const f3 = document.createElement("div");
f3.className = "progress-fill info";
f3.style.width = pi + "%";
bar.append(f1, f2, f3);
dist.append(bar);
section.append(dist);
root.append(section);
// Files overview
const section2 = document.createElement("div");
section2.className = "summary-section files-overview";
const header2 = document.createElement("div");
header2.className = "summary-header";
const hTitle2 = document.createElement("div");
hTitle2.className = "summary-title";
const h22 = document.createElement("h2");
h22.textContent = "Статистика файлов";
const count = document.createElement("span");
count.className = "severity-count";
count.textContent = files.length;
hTitle2.append(h22, count);
header2.append(hTitle2);
section2.append(header2);
const filesGrid = document.createElement("div");
filesGrid.className = "files-grid";
for (let i = 0; i < files.length; i++) {
const f = files[i];
const v = countViolations(f.v);
const t = v.c + v.w + v.i || 1;
const card = document.createElement("div");
card.className = "file-card";
const header = document.createElement("div");
header.className = "file-card-header";
const name = document.createElement("span");
name.className = "file-name-small";
name.textContent = f.n;
header.append(name);
card.append(header);
const bar = document.createElement("div");
bar.className = "progress-bar small";
const fc = document.createElement("div");
fc.className = "progress-fill critical";
fc.style.width = (v.c / t * 100) + "%";
const fw = document.createElement("div");
fw.className = "progress-fill warning";
fw.style.width = (v.w / t * 100) + "%";
const fi = document.createElement("div");
fi.className = "progress-fill info";
fi.style.width = (v.i / t * 100) + "%";
bar.append(fc, fw, fi);
card.append(bar);
const bottom = document.createElement("div");
bottom.className = "file-card-bottom";
const total = document.createElement("span");
total.className = "file-total";
total.textContent = "Total: " + (v.c + v.w + v.i);
const badges = document.createElement("div");
badges.className = "file-violations";
function badge(cls, val) {
const b = document.createElement("span");
b.className = `violation-badge ${cls}`;
b.textContent = val;
return b;
}
badges.append(
badge("critical", v.c),
badge("warning", v.w),
badge("info", v.i)
);
bottom.append(total, badges);
card.append(bottom);
filesGrid.append(card);
}
section2.append(filesGrid);
root.append(section2);
return root;
}
/* ---------------------------------------------------------
TAB HANDLER
--------------------------------------------------------- */
tabsList.addEventListener("click", function (e) {
const tab = e.target.closest(".tab");
if (!tab) return;
const targetId = tab.dataset.target;
// activate tab
tabsList.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
// hide all reports
reportsContainer.innerHTML = "";
// lazy render
let content;
if (!rendered.has(targetId)) {
if (targetId === "summary_report") {
content = renderSummary();
} else {
const index = Number(tab.dataset.index);
content = renderFileReport(index);
}
rendered.add(targetId);
} else {
// already rendered → but we removed DOM → re-render
if (targetId === "summary_report") {
content = renderSummary();
} else {
const index = Number(tab.dataset.index);
content = renderFileReport(index);
}
}
reportsContainer.append(content);
window.scrollTo({ top: 0, behavior: "smooth" });
});
/* ---------------------------------------------------------
INIT
--------------------------------------------------------- */
renderTabs();
const first = tabsList.querySelector(".tab");
if (first) first.click();
const tabs = document.querySelector('.tabs');
tabs.addEventListener('wheel', (e) => {
if (e.deltaY !== 0) {
e.preventDefault();
tabs.scrollBy({
left: e.deltaY * 4,
behavior: "smooth"
});
}
}, { passive: false });
});

View File

@@ -7,9 +7,9 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace SQLLinter.Infrastructure.Reporters; namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v3;
public class HtmlReportFormatter_v2 : IReportFormatter public class HtmlReportFormatter : IReportFormatter
{ {
public string Format(List<IRuleViolation> violations) public string Format(List<IRuleViolation> violations)
=> Format(violations, null); => Format(violations, null);
@@ -44,10 +44,19 @@ public class HtmlReportFormatter_v2 : IReportFormatter
// Основной контейнер для отчета // Основной контейнер для отчета
sb.AppendLine(""" sb.AppendLine("""
<!-- Основной контейнер отчёта -->
<main id="main-content">
<!-- Контейнер для всех отчётов -->
<div id="reports-container"></div> <div id="reports-container"></div>
<div id="tabs-container" class="tabs-container">
</main>
<!-- Sticky Tabs -->
<div class="tabs-container">
<div class="tabs" id="tabs-list"></div> <div class="tabs" id="tabs-list"></div>
</div> </div>
"""); """);
GenerateEndingHtml(sb, diagram != null, jsonData); GenerateEndingHtml(sb, diagram != null, jsonData);
@@ -168,7 +177,7 @@ public class HtmlReportFormatter_v2 : IReportFormatter
<style> <style>
"""); """);
sb.AppendLine(LoadResource("HtmlFormatter.css")); sb.AppendLine(LoadResource("HtmlFormatter_v3.css"));
sb.AppendLine("</style></head><body><main id=\"main-content\">"); sb.AppendLine("</style></head><body><main id=\"main-content\">");
} }
@@ -186,7 +195,7 @@ public class HtmlReportFormatter_v2 : IReportFormatter
"""); """);
// Загружаем основной JS // Загружаем основной JS
sb.AppendLine(LoadResource("HtmlFormatter.js")); sb.AppendLine(LoadResource("HtmlFormatter_v3.js"));
sb.AppendLine(""" sb.AppendLine("""
</script> </script>

View File

@@ -41,7 +41,7 @@ public class Reporter : IReporter
Report(violation.ToString()); 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() ReportViolation(new RuleTemplateViolation()
{ {
@@ -52,6 +52,7 @@ public class Reporter : IReporter
Column = column, Column = column,
Severity = severity, Severity = severity,
Params = param.ToList(), Params = param.ToList(),
Snippet = snippet,
}); });
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ public class AlterProcedureInDboRule : BaseRuleVisitor
{ {
if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true) if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true)
{ {
AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); AddViolation(node.ProcedureReference.Name.SchemaIdentifier, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name));
} }
} }
} }

View File

@@ -17,14 +17,20 @@ public class ConditionalBeginEndRule : BaseRuleVisitor, IRule
public override void Visit(IfStatement node) public override void Visit(IfStatement node)
{ {
if (node.ThenStatement is not BeginEndBlockStatement) if (node.ThenStatement != null && node.ThenStatement is not BeginEndBlockStatement && node.ThenStatement is not TryCatchStatement)
{ {
AddViolation(node); if (node.ThenStatement.StartLine != node.StartLine || node.ScriptTokenStream.Where(t => t.Offset <= node.ThenStatement.StartOffset + node.ThenStatement.FragmentLength).Max(t => t.Line) != node.StartLine)
{
AddViolation(node.ThenStatement);
}
} }
if (node.ElseStatement != null && node.ElseStatement is not BeginEndBlockStatement && node.ElseStatement is not IfStatement) if (node.ElseStatement != null && node.ElseStatement is not BeginEndBlockStatement && node.ElseStatement is not TryCatchStatement && node.ElseStatement is not IfStatement)
{ {
AddViolation(Name, Text, GetLineNumber(node.ElseStatement), GetColumnNumber(node.ElseStatement)); if (node.ElseStatement.StartLine != node.StartLine || node.ScriptTokenStream.Where(t => t.Offset <= node.ElseStatement.StartOffset + node.ElseStatement.FragmentLength).Max(t => t.Line) != node.StartLine)
{
AddViolation(node.ElseStatement);
}
} }
} }

View File

@@ -12,14 +12,14 @@ public class CreateProcedureInDboRule : BaseRuleVisitor
{ {
if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true) if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true)
{ {
AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); AddViolation(node.ProcedureReference.Name.SchemaIdentifier, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name));
} }
} }
public override void Visit(CreateOrAlterProcedureStatement node) public override void Visit(CreateOrAlterProcedureStatement node)
{ {
if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true) if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true)
{ {
AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); AddViolation(node.ProcedureReference.Name.SchemaIdentifier, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name));
} }
} }
} }

View File

@@ -20,6 +20,6 @@ public class DeleteWhereRule : BaseRuleVisitor, IRule
.Select(t => t.Text) .Select(t => t.Text)
); );
AddViolation(node, name); AddViolation(node.Target, name);
} }
} }

View File

@@ -25,6 +25,6 @@ public class ExecuteAsOwnerRule : BaseRuleVisitor
} }
} }
AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); AddViolation(node.ProcedureReference.Name, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name));
} }
} }

View File

@@ -20,9 +20,9 @@ public class HeaderCommentRule : BaseRuleVisitor
public override void Visit(CreateOrAlterTriggerStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.Name)); public override void Visit(CreateOrAlterTriggerStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.Name));
public override void Visit(CreateViewStatement node) => private_visit(node, ""); public override void Visit(CreateViewStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.SchemaObjectName));
public override void Visit(CreateOrAlterViewStatement node) => private_visit(node, ""); public override void Visit(CreateOrAlterViewStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.SchemaObjectName));
private void private_visit(TSqlFragment node, string name) private void private_visit(TSqlFragment node, string name)
{ {
@@ -39,8 +39,24 @@ public class HeaderCommentRule : BaseRuleVisitor
if (prevToken == null || if (prevToken == null ||
prevToken.TokenType != TSqlTokenType.SingleLineComment && prevToken.TokenType != TSqlTokenType.MultilineComment prevToken.TokenType != TSqlTokenType.SingleLineComment && prevToken.TokenType != TSqlTokenType.MultilineComment
) )
{
if (node is ProcedureStatementBody proc)
{
AddViolation(proc.ProcedureReference.Name, name);
}
else if (node is ViewStatementBody view)
{
AddViolation(view.SchemaObjectName, name);
}
else if (node is TriggerStatementBody tr)
{
AddViolation(tr.Name, name);
}
else
{ {
AddViolation(node, name); AddViolation(node, name);
} }
} }
} }
}

View File

@@ -11,7 +11,7 @@ public class InsertStarRule : BaseRuleVisitor
{ {
if (node.InsertSpecification.Columns.Count == 0) // INSERT без перечисления колонок if (node.InsertSpecification.Columns.Count == 0) // INSERT без перечисления колонок
{ {
AddViolation(node); AddViolation(node.InsertSpecification.Target);
} }
} }
} }

View 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);
}
}
}

View File

@@ -12,7 +12,7 @@ public class ProcedureLoggingRule : BaseRuleVisitor
public override void Visit(CreateOrAlterProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); public override void Visit(CreateOrAlterProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name));
public override void Visit(AlterProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); public override void Visit(AlterProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name));
private void check(TSqlStatement node, string name) private void check(ProcedureStatementBody node, string name)
{ {
var tokens = node.ScriptTokenStream; var tokens = node.ScriptTokenStream;
@@ -35,7 +35,7 @@ public class ProcedureLoggingRule : BaseRuleVisitor
if (!hasDebugLog || !hasLabelFinish) if (!hasDebugLog || !hasLabelFinish)
{ {
AddViolation(node, name); AddViolation(node.ProcedureReference.Name, name);
} }
} }
} }

View File

@@ -15,6 +15,7 @@ namespace SQLLinter.Infrastructure.Rules.RuleViolations
required public RuleViolationSeverity Severity { get; init; } required public RuleViolationSeverity Severity { get; init; }
virtual public string Text { get; set; } virtual public string Text { get; set; }
public BaseRuleVisitor.ExtractedBlock? Snippet { get; set; }
} }
public class RuleTemplateViolation : RuleViolation public class RuleTemplateViolation : RuleViolation

View File

@@ -12,7 +12,7 @@ public class TempTableModificationRule : BaseRuleVisitor
{ {
if (node.UpdateSpecification.Target is NamedTableReference tbl && tbl.SchemaObject.BaseIdentifier.Value.StartsWith("#")) if (node.UpdateSpecification.Target is NamedTableReference tbl && tbl.SchemaObject.BaseIdentifier.Value.StartsWith("#"))
{ {
AddViolation(node, SQLHelpers.ObjectGetFullName(tbl.SchemaObject)); AddViolation(node.UpdateSpecification.Target, SQLHelpers.ObjectGetFullName(tbl.SchemaObject));
} }
} }
@@ -20,7 +20,7 @@ public class TempTableModificationRule : BaseRuleVisitor
{ {
if (node.DeleteSpecification.Target is NamedTableReference tbl && tbl.SchemaObject.BaseIdentifier.Value.StartsWith("#")) if (node.DeleteSpecification.Target is NamedTableReference tbl && tbl.SchemaObject.BaseIdentifier.Value.StartsWith("#"))
{ {
AddViolation(node, SQLHelpers.ObjectGetFullName(tbl.SchemaObject)); AddViolation(node.DeleteSpecification.Target, SQLHelpers.ObjectGetFullName(tbl.SchemaObject));
} }
} }
@@ -28,7 +28,7 @@ public class TempTableModificationRule : BaseRuleVisitor
{ {
if (node.SchemaObjectName.BaseIdentifier.Value.StartsWith("#")) if (node.SchemaObjectName.BaseIdentifier.Value.StartsWith("#"))
{ {
AddViolation(node, SQLHelpers.ObjectGetFullName(node.SchemaObjectName)); AddViolation(node.SchemaObjectName, SQLHelpers.ObjectGetFullName(node.SchemaObjectName));
} }
} }
} }

View File

@@ -23,6 +23,6 @@ public class UpdateWhereRule : BaseRuleVisitor, IRule
.Select(t => t.Text) .Select(t => t.Text)
); );
AddViolation(node, name); AddViolation(node.Target, name);
} }
} }

View File

@@ -34,7 +34,7 @@ public class Linter
var ruleVisitor = new SqlRuleVisitor(_pluginHandler, fragmentBuilder, _reporter, sqlStreamReaderBuilder); var ruleVisitor = new SqlRuleVisitor(_pluginHandler, fragmentBuilder, _reporter, sqlStreamReaderBuilder);
_fileProcessor = new SqlFileProcessor(ruleVisitor, _pluginHandler); _fileProcessor = new SqlFileProcessor(config, ruleVisitor, _pluginHandler);
_reporter.Report($"SQL Linter загружен..."); _reporter.Report($"SQL Linter загружен...");
} }

View File

@@ -23,6 +23,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Infrastructure\Reporters\Formatters\Html\v2\HtmlFormatter_v2.js" />
<EmbeddedResource Include="Infrastructure\Reporters\Formatters\Html\v2\HtmlFormatter_v2.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Formatters\Html\v3\HtmlFormatter_v3.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Formatters\Html\v3\HtmlFormatter_v3.js" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter - Копировать.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatterOld.js" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter.css" /> <EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter.js" /> <EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter.js" />
</ItemGroup> </ItemGroup>