Files
code-utils/service/insights.go

202 lines
7.1 KiB
Go
Raw Normal View History

2026-08-11 19:07:05 +08:00
package service
import (
"bufio"
"context"
"os"
"path/filepath"
"sort"
"strings"
"time"
"view/model"
"view/platform"
)
const (
insightMaxFileSize = 1024 * 1024
insightMaxTotalRead = 20 * 1024 * 1024
)
// InsightService 基于既有统计快照做维护性检查。
// 它不会重新遍历整个仓库,只读取已记录的小型源码文件,用于发现 TODO、长文件等轻量风险。
type InsightService struct{}
func (InsightService) Analyze(ctx context.Context, project model.Project, structure model.StructureStats, git model.GitStats) (model.ProjectInsights, error) {
desc, e := platform.ResolvePath(project.Path)
if e != nil {
return model.ProjectInsights{}, e
}
out := model.ProjectInsights{ProjectID: project.ID, GeneratedAt: time.Now().Format(time.RFC3339), Issues: []model.InsightIssue{}}
add := func(issue model.InsightIssue) {
out.Issues = append(out.Issues, issue)
switch issue.Severity {
case "high":
out.Summary.High++
case "medium":
out.Summary.Medium++
default:
out.Summary.Low++
}
}
checkLanguageHealth(project.Languages, add)
out.Summary.LargeFiles = len(structure.LargeFiles)
checkStructureHealth(structure, add)
checkGitHealth(git, add)
if e = scanTextMarkers(ctx, desc.WindowsPath, structure.Files, add, &out.Summary); e != nil {
add(model.InsightIssue{Severity: "medium", Type: "scan_error", Title: "源码检查未完全完成", Detail: e.Error(), Suggestion: "检查项目目录权限,或重新执行项目统计。"})
}
sort.SliceStable(out.Issues, func(i, j int) bool {
return severityRank(out.Issues[i].Severity) > severityRank(out.Issues[j].Severity)
})
out.HealthScore = healthScore(out.Summary)
return out, nil
}
func checkLanguageHealth(langs []model.LanguageStat, add func(model.InsightIssue)) {
for _, x := range langs {
total := x.Code + x.Comments
if x.Code < 500 || x.Files < 3 || total == 0 {
continue
}
ratio := float64(x.Comments) / float64(total)
if ratio < 0.03 {
add(model.InsightIssue{Severity: "medium", Type: "low_comment_ratio", Title: "注释率偏低", Detail: x.Name + " 的注释占比低于 3%。", Suggestion: "为核心业务、复杂条件和公共接口补充解释性注释。"})
} else if ratio < 0.06 {
add(model.InsightIssue{Severity: "low", Type: "low_comment_ratio", Title: "注释率略低", Detail: x.Name + " 的注释占比低于 6%。", Suggestion: "优先补充复杂模块和团队协作频繁的文件。"})
}
}
}
func checkStructureHealth(s model.StructureStats, add func(model.InsightIssue)) {
for _, f := range s.LargeFiles {
severity := "medium"
if f.Size >= 5*1024*1024 {
severity = "high"
}
add(model.InsightIssue{Severity: severity, Type: "large_file", Title: "发现大文件", Detail: "文件体积可能影响仓库加载和代码审查。", Path: f.Path, Suggestion: "确认是否可以拆分、压缩或移动到对象存储。"})
}
if len(s.Folders) > 0 && s.TotalSize > 0 {
top := s.Folders[0]
if float64(top.Size)/float64(s.TotalSize) > 0.65 && top.Files > 20 {
add(model.InsightIssue{Severity: "medium", Type: "folder_concentration", Title: "目录体量过于集中", Detail: top.Name + " 占据了项目大部分体积。", Path: top.Name, Suggestion: "检查该目录是否混入构建产物、缓存或可拆分模块。"})
}
}
unknown := 0
for _, x := range s.Extensions {
if x.Extension == "" {
unknown += int(x.Files)
}
}
if unknown > 20 {
add(model.InsightIssue{Severity: "low", Type: "unknown_files", Title: "无扩展名文件较多", Detail: "项目中存在较多无扩展名文件。", Suggestion: "确认这些文件是否都是必要脚本、配置或运行产物。"})
}
}
func checkGitHealth(g model.GitStats, add func(model.InsightIssue)) {
if g.CommitCount == 0 || len(g.Contributors) == 0 {
return
}
top := g.Contributors[0]
if float64(top.Commits)/float64(g.CommitCount) > 0.75 && g.CommitCount > 20 {
add(model.InsightIssue{Severity: "medium", Type: "contributor_concentration", Title: "提交集中度较高", Detail: top.Name + " 贡献了超过 75% 的提交。", Suggestion: "关注知识分散、代码评审和关键模块交接风险。"})
}
for _, c := range g.Commits[:min(30, len(g.Commits))] {
if c.Added+c.Deleted > 3000 {
add(model.InsightIssue{Severity: "low", Type: "large_commit", Title: "近期存在大提交", Detail: c.Message, Path: c.Hash, Suggestion: "大提交建议拆分审查,降低回滚和定位问题的成本。"})
}
}
}
func scanTextMarkers(ctx context.Context, root string, files []model.FileEntry, add func(model.InsightIssue), summary *model.InsightSummary) error {
totalRead := int64(0)
for _, f := range files {
if f.IsDir || f.Size <= 0 || f.Size > insightMaxFileSize || !isInsightTextFile(f.Extension) {
continue
}
if totalRead+f.Size > insightMaxTotalRead {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
totalRead += f.Size
path := filepath.Join(root, filepath.FromSlash(f.Path))
lines, markers := inspectTextFile(path)
if lines >= 1500 {
summary.LongFiles++
add(model.InsightIssue{Severity: "high", Type: "long_file", Title: "文件过长", Detail: "文件超过 1500 行,理解和评审成本较高。", Path: f.Path, Suggestion: "按职责拆分模块,提取复用函数或服务。"})
} else if lines >= 800 {
summary.LongFiles++
add(model.InsightIssue{Severity: "medium", Type: "long_file", Title: "文件偏长", Detail: "文件超过 800 行。", Path: f.Path, Suggestion: "关注是否存在多个职责混在同一文件中。"})
}
for _, issue := range markers {
summary.TodoCount++
issue.Path = f.Path
add(issue)
}
}
return nil
}
func inspectTextFile(path string) (int, []model.InsightIssue) {
file, e := os.Open(path)
if e != nil {
return 0, nil
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
line := 0
issues := []model.InsightIssue{}
for scanner.Scan() {
line++
text := scanner.Text()
upper := strings.ToUpper(text)
kind, severity := "", "low"
switch {
case strings.Contains(upper, "FIXME"):
kind, severity = "FIXME", "medium"
case strings.Contains(upper, "HACK"):
kind, severity = "HACK", "medium"
case strings.Contains(upper, "TODO"):
kind = "TODO"
}
if kind != "" {
issues = append(issues, model.InsightIssue{Severity: severity, Type: "todo_marker", Title: "发现 " + kind + " 标记", Detail: "源码中存在待处理标记。", Line: line, Suggestion: "确认该标记是否仍有效,并补充负责人或处理计划。", Evidence: strings.TrimSpace(text)})
}
}
return line, issues
}
func isInsightTextFile(ext string) bool {
switch strings.ToLower(ext) {
case ".go", ".php", ".js", ".ts", ".vue", ".jsx", ".tsx", ".css", ".scss", ".sass", ".less", ".html", ".md", ".json", ".yaml", ".yml", ".xml", ".sql", ".py", ".java", ".cs", ".rb", ".rs", ".c", ".cpp", ".h":
return true
default:
return false
}
}
func healthScore(s model.InsightSummary) int {
score := 100 - s.High*12 - s.Medium*6 - s.Low*2
if score < 0 {
return 0
}
return score
}
func severityRank(s string) int {
switch s {
case "high":
return 3
case "medium":
return 2
default:
return 1
}
}