mirror of
https://github.com/ludo-technologies/pyscn.git
synced 2025-10-06 00:59:45 +03:00
## HTML Report Quality Improvements - Fix dead code table displaying actual data instead of empty rows - Correct file paths showing real paths instead of "unknown" - Fix clone type display showing "Type-1", "Type-2" etc. instead of blank cells - Improve template loop logic for proper item limiting ## Report Format Standardization - Create shared FormatUtils for consistent formatting across all analysis types - Standardize header widths, label alignment, and section structures - Unify color schemes and risk level representations - Add comprehensive summary statistics and warning sections ## Health Score Algorithm Enhancement - Add project size normalization using logarithmic scaling for large projects - Implement penalty caps: max 25 points per category (Complexity, Dead Code, Clones, CBO) - Set minimum score threshold of 10 points to avoid complete failure - Adjust grade thresholds: A(85+), B(70+), C(55+), D(40+), F(<40) - Fix scoring issue where large projects always got 0/100 (Grade: F) ## Test Coverage Expansion - Add comprehensive dead code test cases in testdata/python/ - Create simple, edge cases, and complex dead code pattern examples - Improve test coverage for various unreachable code scenarios ## Results - Large projects: 0/100 (F) → 50/100 (D) - Small projects: appropriate scores (60-70 range) - HTML reports now show detailed, accurate information - Consistent professional formatting across all analysis types 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
281 lines
9.9 KiB
Go
281 lines
9.9 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/ludo-technologies/pyscn/domain"
|
|
)
|
|
|
|
// CloneOutputFormatter implements the domain.CloneOutputFormatter interface
|
|
type CloneOutputFormatter struct{}
|
|
|
|
// NewCloneOutputFormatter creates a new clone output formatter
|
|
func NewCloneOutputFormatter() *CloneOutputFormatter {
|
|
return &CloneOutputFormatter{}
|
|
}
|
|
|
|
// FormatCloneResponse formats a clone response according to the specified format
|
|
func (f *CloneOutputFormatter) FormatCloneResponse(response *domain.CloneResponse, format domain.OutputFormat, writer io.Writer) error {
|
|
switch format {
|
|
case domain.OutputFormatText:
|
|
return f.formatAsText(response, writer)
|
|
case domain.OutputFormatJSON:
|
|
return WriteJSON(writer, response)
|
|
case domain.OutputFormatYAML:
|
|
return WriteYAML(writer, response)
|
|
case domain.OutputFormatCSV:
|
|
return f.formatAsCSV(response, writer)
|
|
case domain.OutputFormatHTML:
|
|
return f.formatAsHTML(response, writer)
|
|
default:
|
|
return domain.NewUnsupportedFormatError(string(format))
|
|
}
|
|
}
|
|
|
|
// FormatCloneStatistics formats clone statistics
|
|
func (f *CloneOutputFormatter) FormatCloneStatistics(stats *domain.CloneStatistics, format domain.OutputFormat, writer io.Writer) error {
|
|
switch format {
|
|
case domain.OutputFormatText:
|
|
return f.formatStatsAsText(stats, writer)
|
|
case domain.OutputFormatJSON:
|
|
return WriteJSON(writer, stats)
|
|
case domain.OutputFormatYAML:
|
|
return WriteYAML(writer, stats)
|
|
case domain.OutputFormatCSV:
|
|
return f.formatStatsAsCSV(stats, writer)
|
|
case domain.OutputFormatHTML:
|
|
return f.formatStatsAsHTML(stats, writer)
|
|
default:
|
|
return domain.NewUnsupportedFormatError(string(format))
|
|
}
|
|
}
|
|
|
|
// formatAsText formats the response as human-readable text
|
|
func (f *CloneOutputFormatter) formatAsText(response *domain.CloneResponse, writer io.Writer) error {
|
|
if !response.Success {
|
|
fmt.Fprintf(writer, "Clone detection failed: %s\n", response.Error)
|
|
return nil
|
|
}
|
|
|
|
utils := NewFormatUtils()
|
|
|
|
// Header
|
|
fmt.Fprint(writer, utils.FormatMainHeader("Clone Detection Analysis Report"))
|
|
|
|
// Summary
|
|
if response.Statistics != nil {
|
|
stats := map[string]interface{}{
|
|
"Files Analyzed": response.Statistics.FilesAnalyzed,
|
|
"Lines Analyzed": response.Statistics.LinesAnalyzed,
|
|
"Clone Pairs": response.Statistics.TotalClonePairs,
|
|
"Clone Groups": response.Statistics.TotalCloneGroups,
|
|
"Average Similarity": fmt.Sprintf("%.3f", response.Statistics.AverageSimilarity),
|
|
"Analysis Duration": utils.FormatDuration(response.Duration),
|
|
}
|
|
fmt.Fprint(writer, utils.FormatSummaryStats(stats))
|
|
}
|
|
|
|
// Clone Types breakdown
|
|
if response.Statistics != nil && len(response.Statistics.ClonesByType) > 0 {
|
|
fmt.Fprint(writer, utils.FormatSectionHeader("CLONE TYPES"))
|
|
for cloneType, count := range response.Statistics.ClonesByType {
|
|
fmt.Fprint(writer, utils.FormatLabelWithIndent(SectionPadding, cloneType, fmt.Sprintf("%d pairs", count)))
|
|
}
|
|
fmt.Fprint(writer, utils.FormatSectionSeparator())
|
|
}
|
|
|
|
if len(response.ClonePairs) == 0 {
|
|
fmt.Fprint(writer, utils.FormatSectionHeader("RESULTS"))
|
|
fmt.Fprint(writer, utils.FormatLabelWithIndent(SectionPadding, "Status", "No clones detected"))
|
|
return nil
|
|
}
|
|
|
|
// Detailed clone information
|
|
if response.Request != nil && response.Request.GroupClones && len(response.CloneGroups) > 0 {
|
|
fmt.Fprint(writer, utils.FormatSectionHeader("CLONE GROUPS"))
|
|
|
|
for _, group := range response.CloneGroups {
|
|
if group == nil {
|
|
continue
|
|
}
|
|
fmt.Fprint(writer, utils.FormatLabelWithIndent(0, "Group", fmt.Sprintf("%d (%s, %d clones, similarity: %.3f)",
|
|
group.ID, group.Type.String(), group.Size, group.Similarity)))
|
|
|
|
for i, clone := range group.Clones {
|
|
if clone == nil || clone.Location == nil {
|
|
continue
|
|
}
|
|
fmt.Fprint(writer, utils.FormatLabelWithIndent(ItemPadding, fmt.Sprintf("Clone %d", i+1),
|
|
fmt.Sprintf("%s (%d lines, %d nodes)", clone.Location.String(), clone.LineCount, clone.Size)))
|
|
}
|
|
fmt.Fprint(writer, "\n")
|
|
}
|
|
} else {
|
|
fmt.Fprint(writer, utils.FormatSectionHeader("CLONE PAIRS"))
|
|
|
|
for i, pair := range response.ClonePairs {
|
|
if pair == nil {
|
|
continue
|
|
}
|
|
fmt.Fprint(writer, utils.FormatLabelWithIndent(0, fmt.Sprintf("Pair %d", i+1),
|
|
fmt.Sprintf("%s (similarity: %.3f, confidence: %.3f)", pair.Type.String(), pair.Similarity, pair.Confidence)))
|
|
|
|
if pair.Clone1 != nil && pair.Clone1.Location != nil {
|
|
fmt.Fprint(writer, utils.FormatLabelWithIndent(SectionPadding, "Clone 1",
|
|
fmt.Sprintf("%s (%d lines, %d nodes)", pair.Clone1.Location.String(), pair.Clone1.LineCount, pair.Clone1.Size)))
|
|
}
|
|
|
|
if pair.Clone2 != nil && pair.Clone2.Location != nil {
|
|
fmt.Fprint(writer, utils.FormatLabelWithIndent(SectionPadding, "Clone 2",
|
|
fmt.Sprintf("%s (%d lines, %d nodes)", pair.Clone2.Location.String(), pair.Clone2.LineCount, pair.Clone2.Size)))
|
|
}
|
|
|
|
if response.Request != nil && response.Request.ShowContent && pair.Clone1 != nil && pair.Clone1.Content != "" {
|
|
fmt.Fprint(writer, utils.FormatLabelWithIndent(SectionPadding, "Preview", ""))
|
|
lines := strings.Split(pair.Clone1.Content, "\n")
|
|
for j, line := range lines {
|
|
if j >= 5 { // Limit preview to 5 lines
|
|
fmt.Fprintf(writer, "%s...\n", strings.Repeat(" ", ItemPadding+2))
|
|
break
|
|
}
|
|
fmt.Fprintf(writer, "%s%s\n", strings.Repeat(" ", ItemPadding+2), line)
|
|
}
|
|
}
|
|
|
|
fmt.Fprint(writer, "\n")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatAsJSON formats the response as JSON
|
|
// formatAsCSV formats the response as CSV
|
|
func (f *CloneOutputFormatter) formatAsCSV(response *domain.CloneResponse, writer io.Writer) error {
|
|
csvWriter := csv.NewWriter(writer)
|
|
defer csvWriter.Flush()
|
|
|
|
// Write header
|
|
header := []string{
|
|
"pair_id", "clone_type", "similarity", "confidence", "distance",
|
|
"clone1_file", "clone1_start_line", "clone1_end_line", "clone1_size", "clone1_lines",
|
|
"clone2_file", "clone2_start_line", "clone2_end_line", "clone2_size", "clone2_lines",
|
|
}
|
|
if err := csvWriter.Write(header); err != nil {
|
|
return fmt.Errorf("failed to write CSV header: %w", err)
|
|
}
|
|
|
|
// Write clone pairs
|
|
for _, pair := range response.ClonePairs {
|
|
record := []string{
|
|
fmt.Sprintf("%d", pair.ID),
|
|
pair.Type.String(),
|
|
fmt.Sprintf("%.6f", pair.Similarity),
|
|
fmt.Sprintf("%.6f", pair.Confidence),
|
|
fmt.Sprintf("%.2f", pair.Distance),
|
|
pair.Clone1.Location.FilePath,
|
|
fmt.Sprintf("%d", pair.Clone1.Location.StartLine),
|
|
fmt.Sprintf("%d", pair.Clone1.Location.EndLine),
|
|
fmt.Sprintf("%d", pair.Clone1.Size),
|
|
fmt.Sprintf("%d", pair.Clone1.LineCount),
|
|
pair.Clone2.Location.FilePath,
|
|
fmt.Sprintf("%d", pair.Clone2.Location.StartLine),
|
|
fmt.Sprintf("%d", pair.Clone2.Location.EndLine),
|
|
fmt.Sprintf("%d", pair.Clone2.Size),
|
|
fmt.Sprintf("%d", pair.Clone2.LineCount),
|
|
}
|
|
if err := csvWriter.Write(record); err != nil {
|
|
return fmt.Errorf("failed to write CSV record: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatStatsAsText formats statistics as text
|
|
func (f *CloneOutputFormatter) formatStatsAsText(stats *domain.CloneStatistics, writer io.Writer) error {
|
|
fmt.Fprintf(writer, "Clone Detection Statistics\n")
|
|
fmt.Fprintf(writer, "==========================\n\n")
|
|
fmt.Fprintf(writer, "Files analyzed: %d\n", stats.FilesAnalyzed)
|
|
fmt.Fprintf(writer, "Lines analyzed: %d\n", stats.LinesAnalyzed)
|
|
fmt.Fprintf(writer, "Clone pairs: %d\n", stats.TotalClonePairs)
|
|
fmt.Fprintf(writer, "Clone groups: %d\n", stats.TotalCloneGroups)
|
|
|
|
if stats.AverageSimilarity > 0 {
|
|
fmt.Fprintf(writer, "Average similarity: %.3f\n", stats.AverageSimilarity)
|
|
}
|
|
|
|
if len(stats.ClonesByType) > 0 {
|
|
fmt.Fprintf(writer, "\nClone types:\n")
|
|
for cloneType, count := range stats.ClonesByType {
|
|
fmt.Fprintf(writer, " %s: %d\n", cloneType, count)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatStatsAsJSON and formatStatsAsYAML were replaced by shared helpers in format_utils.go
|
|
|
|
// formatStatsAsCSV formats statistics as CSV
|
|
func (f *CloneOutputFormatter) formatStatsAsCSV(stats *domain.CloneStatistics, writer io.Writer) error {
|
|
csvWriter := csv.NewWriter(writer)
|
|
defer csvWriter.Flush()
|
|
|
|
// Write basic statistics
|
|
records := [][]string{
|
|
{"metric", "value"},
|
|
{"files_analyzed", fmt.Sprintf("%d", stats.FilesAnalyzed)},
|
|
{"lines_analyzed", fmt.Sprintf("%d", stats.LinesAnalyzed)},
|
|
{"total_clones", fmt.Sprintf("%d", stats.TotalClones)},
|
|
{"total_clone_pairs", fmt.Sprintf("%d", stats.TotalClonePairs)},
|
|
{"total_clone_groups", fmt.Sprintf("%d", stats.TotalCloneGroups)},
|
|
{"average_similarity", fmt.Sprintf("%.6f", stats.AverageSimilarity)},
|
|
}
|
|
|
|
for _, record := range records {
|
|
if err := csvWriter.Write(record); err != nil {
|
|
return fmt.Errorf("failed to write CSV record: %w", err)
|
|
}
|
|
}
|
|
|
|
// Write clone type breakdown
|
|
for cloneType, count := range stats.ClonesByType {
|
|
record := []string{fmt.Sprintf("clone_type_%s", strings.ToLower(cloneType)), fmt.Sprintf("%d", count)}
|
|
if err := csvWriter.Write(record); err != nil {
|
|
return fmt.Errorf("failed to write CSV record: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatAsHTML formats the response as Lighthouse-style HTML
|
|
func (f *CloneOutputFormatter) formatAsHTML(response *domain.CloneResponse, writer io.Writer) error {
|
|
htmlFormatter := NewHTMLFormatter()
|
|
projectName := "Python Project" // Default project name, could be configurable
|
|
|
|
htmlContent, err := htmlFormatter.FormatCloneAsHTML(response, projectName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to format as HTML: %w", err)
|
|
}
|
|
|
|
_, err = writer.Write([]byte(htmlContent))
|
|
return err
|
|
}
|
|
|
|
// formatStatsAsHTML formats statistics as HTML
|
|
func (f *CloneOutputFormatter) formatStatsAsHTML(stats *domain.CloneStatistics, writer io.Writer) error {
|
|
// Create a minimal clone response with only statistics for HTML formatting
|
|
response := &domain.CloneResponse{
|
|
Success: true,
|
|
Statistics: stats,
|
|
ClonePairs: []*domain.ClonePair{}, // Empty pairs for stats-only view
|
|
}
|
|
|
|
return f.formatAsHTML(response, writer)
|
|
}
|