Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1efa6764d1 | ||
|
|
edc4d5c087 | ||
|
|
507c466b5d | ||
|
|
7fb11364c4 | ||
|
|
19c2357c04 | ||
|
|
3c4eda7f57 | ||
|
|
119d94b0e8 | ||
|
|
bf6c0b9229 | ||
|
|
0470309978 | ||
|
|
cc7809871e | ||
|
|
e4acae11f0 | ||
|
|
f988d9af1e | ||
|
|
66f1166166 | ||
|
|
f103bc4ec4 |
@@ -2,6 +2,7 @@
|
||||
using SQLLinter.Infrastructure.Diagram;
|
||||
using SQLLinter.Infrastructure.Parser;
|
||||
using SQLLinter.Infrastructure.Reporters;
|
||||
using F = SQLLinter.Infrastructure.Reporters.Formatters.Html;
|
||||
|
||||
namespace SQLLinter.CLI
|
||||
{
|
||||
@@ -42,7 +43,9 @@ namespace SQLLinter.CLI
|
||||
["UpdateWhere"] = Common.RuleViolationSeverity.Critical,
|
||||
["UpperLower"] = Common.RuleViolationSeverity.Critical,
|
||||
["SetVariable"] = Common.RuleViolationSeverity.Critical,
|
||||
}
|
||||
["CreateProcedureInDbo"] = Common.RuleViolationSeverity.Warning,
|
||||
},
|
||||
GenerateDetails = false,
|
||||
};
|
||||
|
||||
//var linter = new Linter(con, rep);
|
||||
@@ -56,16 +59,35 @@ namespace SQLLinter.CLI
|
||||
|
||||
using (StreamReader reader = new StreamReader(@"C:\Users\frost\Downloads\Telegram Desktop\test.sql"))
|
||||
{
|
||||
linter.Run("test.sql", reader.BaseStream);
|
||||
var name = "test-qwewq-asdcxczc-asdsa -s--sadsasd-dsads-dsa-d-sd--dsa - 0.sql";
|
||||
|
||||
Dictionary<string, Stream> files = new();
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
files[name + i + ".sql"] = reader.BaseStream;
|
||||
}
|
||||
|
||||
linter.Run(files);
|
||||
diagramer.Run("test.sql", reader.BaseStream);
|
||||
}
|
||||
|
||||
//linter.Run(@"C:\Users\frost\Desktop\DISTR-2599\test.sql");
|
||||
|
||||
var formatter = new HtmlReportFormatter_v1();
|
||||
IReportFormatter formatter = new F.v3.HtmlReportFormatter();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
SQLLinter.CLI/Properties/launchSettings.json
Normal file
8
SQLLinter.CLI/Properties/launchSettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"profiles": {
|
||||
"SQLLinter.CLI": {
|
||||
"commandName": "Project",
|
||||
"hotReloadEnabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.SqlServer.TransactSql.ScriptDom;
|
||||
using System.Text;
|
||||
|
||||
namespace SQLLinter.Common;
|
||||
|
||||
@@ -6,6 +7,15 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
|
||||
{
|
||||
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 DynamicSqlStartLine { get; set; }
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
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>();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -14,4 +14,5 @@ public interface IRule
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -20,5 +20,10 @@ namespace SQLLinter.Core.Interfaces
|
||||
/// Список сторонних плагинов.
|
||||
/// </summary>
|
||||
List<string> Plugins { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Генерировать деталировку ошибки.
|
||||
/// </summary>
|
||||
bool GenerateDetails { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace SQLLinter.Core.Interfaces;
|
||||
|
||||
public interface IRuleVisitor
|
||||
{
|
||||
void VisitRules(string path, IEnumerable<IRuleException> igoredRules, Stream sqlFileStream);
|
||||
void VisitRules(string path, IEnumerable<IRuleException> igoredRules, Stream sqlFileStream, bool generateDetails);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ namespace SQLLinter.Infrastructure.Configuration
|
||||
public int CompatibilityLevel { get; set; }
|
||||
public Dictionary<string, RuleViolationSeverity> Rules { get; set; } = new();
|
||||
public List<string> Plugins { get; set; } = new();
|
||||
public bool GenerateDetails { get; set; } = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ public static class BpmnBuilder
|
||||
{
|
||||
var visitor = new BpmnVisitor(diagram);
|
||||
fragment.Accept(visitor);
|
||||
visitor.Diagram.AddMissingProcessEdges();
|
||||
return visitor.Diagram;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ public class SqlDiagramProcessor : ISqlDiagramProcessor
|
||||
}
|
||||
|
||||
BpmnBuilder.Build(fragment, _bpmnDiagram);
|
||||
_bpmnDiagram.AddMissingProcessEdges();
|
||||
}
|
||||
|
||||
private Stream GetFileContents(string filePath)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@ namespace SQLLinter.Infrastructure.Parser;
|
||||
public class SqlFileProcessor : ISqlFileProcessor
|
||||
{
|
||||
private readonly IRuleVisitor ruleVisitor;
|
||||
private readonly IConfig _config;
|
||||
|
||||
private readonly IRuleExceptionFinder ruleExceptionFinder;
|
||||
|
||||
public SqlFileProcessor(
|
||||
public SqlFileProcessor(IConfig config,
|
||||
IRuleVisitor ruleVisitor,
|
||||
IPluginHandler pluginHandler
|
||||
)
|
||||
{
|
||||
this._config = config;
|
||||
this.ruleVisitor = ruleVisitor;
|
||||
ruleExceptionFinder = new RuleExceptionFinder(pluginHandler.RuleWithNames);
|
||||
}
|
||||
@@ -93,7 +95,7 @@ public class SqlFileProcessor : ISqlFileProcessor
|
||||
|
||||
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)
|
||||
|
||||
@@ -29,7 +29,7 @@ public class SqlRuleVisitor : IRuleVisitor
|
||||
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 overrideArray = overrides as IOverride[] ?? overrides.ToArray();
|
||||
@@ -38,6 +38,9 @@ public class SqlRuleVisitor : IRuleVisitor
|
||||
|
||||
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();
|
||||
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)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
using SQLLinter.Infrastructure.Diagram;
|
||||
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)
|
||||
=> Format(violations, null);
|
||||
@@ -143,6 +143,6 @@ public class HtmlReportFormatter_v1 : IReportFormatter
|
||||
sb.AppendLine("</body>");
|
||||
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
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
|
||||
});
|
||||
@@ -7,9 +7,9 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
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)
|
||||
=> Format(violations, null);
|
||||
@@ -44,10 +44,19 @@ public class HtmlReportFormatter_v2 : IReportFormatter
|
||||
|
||||
// Основной контейнер для отчета
|
||||
sb.AppendLine("""
|
||||
<div id="reports-container"></div>
|
||||
<div id="tabs-container" class="tabs-container">
|
||||
<!-- Основной контейнер отчёта -->
|
||||
<main id="main-content">
|
||||
|
||||
<!-- Контейнер для всех отчётов -->
|
||||
<div id="reports-container"></div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Sticky Tabs -->
|
||||
<div class="tabs-container">
|
||||
<div class="tabs" id="tabs-list"></div>
|
||||
</div>
|
||||
|
||||
""");
|
||||
|
||||
GenerateEndingHtml(sb, diagram != null, jsonData);
|
||||
@@ -168,7 +177,7 @@ public class HtmlReportFormatter_v2 : IReportFormatter
|
||||
<style>
|
||||
""");
|
||||
|
||||
sb.AppendLine(LoadResource("HtmlFormatter.css"));
|
||||
sb.AppendLine(LoadResource("HtmlFormatter_v3.css"));
|
||||
sb.AppendLine("</style></head><body><main id=\"main-content\">");
|
||||
}
|
||||
|
||||
@@ -186,7 +195,7 @@ public class HtmlReportFormatter_v2 : IReportFormatter
|
||||
""");
|
||||
|
||||
// Загружаем основной JS
|
||||
sb.AppendLine(LoadResource("HtmlFormatter.js"));
|
||||
sb.AppendLine(LoadResource("HtmlFormatter_v3.js"));
|
||||
|
||||
sb.AppendLine("""
|
||||
</script>
|
||||
@@ -5,6 +5,11 @@ public static class HtmlMinifier
|
||||
{
|
||||
public static string MinifyHtml(string html)
|
||||
{
|
||||
|
||||
var htmlMinifier = new WebMarkupMin.Core.HtmlMinifier();
|
||||
var result = htmlMinifier.Minify(html, generateStatistics: true);
|
||||
return result.MinifiedContent;
|
||||
|
||||
if (string.IsNullOrEmpty(html))
|
||||
return html;
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@ namespace SQLLinter.Infrastructure.Reporters;
|
||||
public interface IReportFormatter
|
||||
{
|
||||
string Format(List<IRuleViolation> violations);
|
||||
string Format(List<IRuleViolation> violations, BpmnDiagram diagram);
|
||||
string Format(List<IRuleViolation> violations, BpmnDiagram? diagram);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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
1895
SQLLinter/Infrastructure/Reporters/Static/HtmlFormatterOld.js
Normal file
1895
SQLLinter/Infrastructure/Reporters/Static/HtmlFormatterOld.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ public class AlterProcedureInDboRule : BaseRuleVisitor
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,20 @@ public class ConditionalBeginEndRule : BaseRuleVisitor, IRule
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ public class CreateProcedureInDboRule : BaseRuleVisitor
|
||||
{
|
||||
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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ public class DeleteWhereRule : BaseRuleVisitor, IRule
|
||||
.Select(t => t.Text)
|
||||
);
|
||||
|
||||
AddViolation(node, name);
|
||||
AddViolation(node.Target, name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@ public class ExecuteAsOwnerRule : BaseRuleVisitor
|
||||
}
|
||||
}
|
||||
|
||||
AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name));
|
||||
AddViolation(node.ProcedureReference.Name, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(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)
|
||||
{
|
||||
@@ -40,7 +40,23 @@ public class HeaderCommentRule : BaseRuleVisitor
|
||||
prevToken.TokenType != TSqlTokenType.SingleLineComment && prevToken.TokenType != TSqlTokenType.MultilineComment
|
||||
)
|
||||
{
|
||||
AddViolation(node, name);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public class InsertStarRule : BaseRuleVisitor
|
||||
{
|
||||
if (node.InsertSpecification.Columns.Count == 0) // INSERT без перечисления колонок
|
||||
{
|
||||
AddViolation(node);
|
||||
AddViolation(node.InsertSpecification.Target);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(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;
|
||||
|
||||
@@ -35,7 +35,7 @@ public class ProcedureLoggingRule : BaseRuleVisitor
|
||||
|
||||
if (!hasDebugLog || !hasLabelFinish)
|
||||
{
|
||||
AddViolation(node, name);
|
||||
AddViolation(node.ProcedureReference.Name, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ public class TempTableModificationRule : BaseRuleVisitor
|
||||
{
|
||||
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("#"))
|
||||
{
|
||||
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("#"))
|
||||
{
|
||||
AddViolation(node, SQLHelpers.ObjectGetFullName(node.SchemaObjectName));
|
||||
AddViolation(node.SchemaObjectName, SQLHelpers.ObjectGetFullName(node.SchemaObjectName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@ public class UpdateWhereRule : BaseRuleVisitor, IRule
|
||||
.Select(t => t.Text)
|
||||
);
|
||||
|
||||
AddViolation(node, name);
|
||||
AddViolation(node.Target, name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class Linter
|
||||
|
||||
var ruleVisitor = new SqlRuleVisitor(_pluginHandler, fragmentBuilder, _reporter, sqlStreamReaderBuilder);
|
||||
|
||||
_fileProcessor = new SqlFileProcessor(ruleVisitor, _pluginHandler);
|
||||
_fileProcessor = new SqlFileProcessor(config, ruleVisitor, _pluginHandler);
|
||||
|
||||
_reporter.Report($"SQL Linter загружен...");
|
||||
}
|
||||
|
||||
@@ -23,12 +23,19 @@
|
||||
</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.js" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="170.128.0" />
|
||||
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="170.147.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user