初始化
This commit is contained in:
321
service/git.go
Normal file
321
service/git.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"view/model"
|
||||
"view/platform"
|
||||
)
|
||||
|
||||
// GitError 使用稳定错误码作为 Error() 返回值,同时保留命令输出细节。
|
||||
// 这样旧调用方仍能按错误码判断,诊断接口也能把真实原因展示给用户。
|
||||
type GitError struct {
|
||||
Code string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *GitError) Error() string { return e.Code }
|
||||
|
||||
// GitErrorDetail 提取 GitError 的详细原因,用于中文日志或诊断面板。
|
||||
func GitErrorDetail(err error) string {
|
||||
var ge *GitError
|
||||
if errors.As(err, &ge) {
|
||||
return ge.Detail
|
||||
}
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GitService 根据项目路径自动选择 Windows Git 或 WSL 内 Git。
|
||||
// 所有命令都经过 platform.RunHidden,确保分析时不会弹出 CMD/PowerShell/WSL 窗口。
|
||||
type GitService struct{}
|
||||
|
||||
func (g GitService) run(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
desc, e := platform.ResolvePath(dir)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if desc.WSL {
|
||||
// WSL 的裸 "-- git ..." 在部分环境会交给默认 shell 解析,
|
||||
// Git format 参数中的 "%(...)" 会被 bash 当成语法。--exec 可直接执行 git。
|
||||
base := []string{"-d", desc.Distro, "--exec", "git", "-C", desc.LinuxPath}
|
||||
out, e := platform.RunHidden(ctx, "wsl.exe", append(base, args...)...)
|
||||
if e != nil {
|
||||
return "", mapGitError(desc, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if _, e = exec.LookPath("git"); e != nil {
|
||||
return "", &GitError{Code: "GIT_NOT_INSTALLED", Detail: e.Error()}
|
||||
}
|
||||
out, e := platform.RunHidden(ctx, "git", append([]string{"-C", desc.WindowsPath}, args...)...)
|
||||
if e != nil {
|
||||
return "", mapGitError(desc, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mapGitError(desc platform.PathDescriptor, err error) error {
|
||||
detail := strings.TrimSpace(err.Error())
|
||||
lower := strings.ToLower(detail)
|
||||
code := "GIT_COMMAND_FAILED"
|
||||
switch {
|
||||
case strings.Contains(lower, "not a git repository"):
|
||||
code = "NOT_GIT_REPOSITORY"
|
||||
case strings.Contains(lower, "detected dubious ownership"):
|
||||
code = "GIT_SAFE_DIRECTORY"
|
||||
case strings.Contains(lower, "permission denied") || strings.Contains(lower, "could not open"):
|
||||
code = "GIT_PERMISSION_DENIED"
|
||||
case strings.Contains(lower, "executable file not found") || strings.Contains(lower, "not recognized"):
|
||||
if desc.WSL {
|
||||
code = "WSL_NOT_INSTALLED"
|
||||
} else {
|
||||
code = "GIT_NOT_INSTALLED"
|
||||
}
|
||||
case strings.Contains(lower, "git: not found") || strings.Contains(lower, "git: command not found"):
|
||||
code = "WSL_GIT_NOT_INSTALLED"
|
||||
case strings.Contains(lower, "there is no distribution") || strings.Contains(lower, "specified distribution") || strings.Contains(lower, "wsl_e_distro_not_found"):
|
||||
code = "WSL_DISTRO_NOT_FOUND"
|
||||
case strings.Contains(lower, "cannot access") && desc.WSL:
|
||||
code = "WSL_PATH_UNREADABLE"
|
||||
case strings.Contains(lower, "unknown revision") || strings.Contains(lower, "bad revision"):
|
||||
code = "GIT_REF_NOT_FOUND"
|
||||
}
|
||||
return &GitError{Code: code, Detail: detail}
|
||||
}
|
||||
|
||||
// Diagnostics 分步探测 Git 状态,供详情页解释“为什么 Git 统计是 0”。
|
||||
func (g GitService) Diagnostics(ctx context.Context, dir string, ref string) model.GitDiagnostics {
|
||||
desc, e := platform.ResolvePath(dir)
|
||||
d := model.GitDiagnostics{}
|
||||
if e != nil {
|
||||
d.ErrorCode = e.Error()
|
||||
d.Detail = e.Error()
|
||||
return d
|
||||
}
|
||||
d.IsWSL, d.Distro, d.LinuxPath = desc.WSL, desc.Distro, desc.LinuxPath
|
||||
if _, e = g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil {
|
||||
d.ErrorCode = e.Error()
|
||||
d.Detail = GitErrorDetail(e)
|
||||
return d
|
||||
}
|
||||
branch, _ := g.run(ctx, dir, "branch", "--show-current")
|
||||
refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes")
|
||||
if e != nil {
|
||||
d.ErrorCode = e.Error()
|
||||
d.Detail = GitErrorDetail(e)
|
||||
return d
|
||||
}
|
||||
if ref == "" {
|
||||
ref = strings.TrimSpace(branch)
|
||||
}
|
||||
refCount := 0
|
||||
for _, line := range strings.Split(refs, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
refCount++
|
||||
}
|
||||
}
|
||||
d.Available = true
|
||||
d.WorkspaceBranch = strings.TrimSpace(branch)
|
||||
d.ViewRef = strings.TrimSpace(ref)
|
||||
d.RefCount = refCount
|
||||
return d
|
||||
}
|
||||
|
||||
// Analyze 分析工作区当前分支。
|
||||
func (g GitService) Analyze(ctx context.Context, dir string) (model.GitStats, error) {
|
||||
return g.AnalyzeRef(ctx, dir, "")
|
||||
}
|
||||
|
||||
// AnalyzeRef 只切换统计视图,不修改用户工作区。
|
||||
func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitStats, error) {
|
||||
if _, e := g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil {
|
||||
return model.GitStats{Available: false, Error: e.Error()}, e
|
||||
}
|
||||
branch, _ := g.run(ctx, dir, "branch", "--show-current")
|
||||
branch = strings.TrimSpace(branch)
|
||||
if ref == "" {
|
||||
ref = branch
|
||||
}
|
||||
out := model.GitStats{Available: true, CurrentBranch: branch, WorkspaceBranch: branch, ViewRef: ref, Commits: []model.GitCommit{}, Refs: []model.GitRef{}, Contributors: []model.Contributor{}, Heatmap: []model.HeatDay{}}
|
||||
refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes")
|
||||
if e != nil {
|
||||
out.Available = false
|
||||
out.Error = e.Error()
|
||||
return out, e
|
||||
}
|
||||
for _, line := range strings.Split(refs, "\n") {
|
||||
p := strings.Split(line, "\x1f")
|
||||
if len(p) != 3 {
|
||||
p = strings.Split(line, "%x1f")
|
||||
}
|
||||
if len(p) != 3 {
|
||||
continue
|
||||
}
|
||||
kind := "local"
|
||||
if strings.HasPrefix(p[2], "refs/remotes/") {
|
||||
kind = "remote"
|
||||
}
|
||||
out.Refs = append(out.Refs, model.GitRef{Name: p[0], Hash: ShortHash(p[1]), Kind: kind, Current: p[0] == branch})
|
||||
}
|
||||
args := []string{"log", "--use-mailmap", "--date=iso-strict", "--pretty=format:@@CC@@%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", "--numstat"}
|
||||
if strings.TrimSpace(ref) != "" {
|
||||
args = append(args, ref)
|
||||
}
|
||||
log, e := g.run(ctx, dir, args...)
|
||||
if e != nil {
|
||||
out.Available = false
|
||||
out.Error = e.Error()
|
||||
return out, e
|
||||
}
|
||||
parseLog(log, &out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CommitDetail 按需读取单次提交和逐文件增删行,避免 Git 首页一次传输过多数据。
|
||||
func (g GitService) CommitDetail(ctx context.Context, dir, hash string) (model.GitCommitDetail, error) {
|
||||
meta, e := g.run(ctx, dir, "show", "-s", "--use-mailmap", "--date=iso-strict", "--format=%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", hash)
|
||||
if e != nil {
|
||||
return model.GitCommitDetail{}, e
|
||||
}
|
||||
p := strings.Split(meta, "\x1f")
|
||||
if len(p) != 5 {
|
||||
return model.GitCommitDetail{}, &GitError{Code: "GIT_COMMIT_PARSE_FAILED", Detail: meta}
|
||||
}
|
||||
d := model.GitCommitDetail{GitCommit: model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]}, Files: []model.GitFileChange{}}
|
||||
nums, e := g.run(ctx, dir, "show", "--format=", "--numstat", hash)
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
for _, line := range strings.Split(nums, "\n") {
|
||||
x := strings.Split(line, "\t")
|
||||
if len(x) < 3 {
|
||||
continue
|
||||
}
|
||||
a, ea := strconv.ParseInt(x[0], 10, 64)
|
||||
del, ed := strconv.ParseInt(x[1], 10, 64)
|
||||
if ea != nil {
|
||||
a = 0
|
||||
}
|
||||
if ed != nil {
|
||||
del = 0
|
||||
}
|
||||
status := "modified"
|
||||
if a > 0 && del == 0 {
|
||||
status = "added"
|
||||
}
|
||||
if del > 0 && a == 0 {
|
||||
status = "deleted"
|
||||
}
|
||||
d.Files = append(d.Files, model.GitFileChange{Path: x[2], Status: status, Added: a, Deleted: del})
|
||||
d.Added += a
|
||||
d.Deleted += del
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// CheckoutBranch 在修改工作区前先检查已跟踪和未跟踪变更;发现任何内容都拒绝切换。
|
||||
func (g GitService) CheckoutBranch(ctx context.Context, dir, ref string) (model.CheckoutResult, error) {
|
||||
dirty, e := g.run(ctx, dir, "status", "--porcelain")
|
||||
if e != nil {
|
||||
return model.CheckoutResult{}, e
|
||||
}
|
||||
if strings.TrimSpace(dirty) != "" {
|
||||
return model.CheckoutResult{}, &GitError{Code: "GIT_WORKTREE_DIRTY", Detail: dirty}
|
||||
}
|
||||
kind, _ := g.run(ctx, dir, "for-each-ref", "--format=%(refname)", "refs/remotes/"+ref)
|
||||
if strings.TrimSpace(kind) != "" {
|
||||
local := ref
|
||||
if i := strings.Index(ref, "/"); i >= 0 {
|
||||
local = ref[i+1:]
|
||||
}
|
||||
if _, x := g.run(ctx, dir, "show-ref", "--verify", "--quiet", "refs/heads/"+local); x == nil {
|
||||
_, e = g.run(ctx, dir, "switch", local)
|
||||
} else {
|
||||
_, e = g.run(ctx, dir, "switch", "--track", ref)
|
||||
}
|
||||
} else {
|
||||
_, e = g.run(ctx, dir, "switch", ref)
|
||||
}
|
||||
if e != nil {
|
||||
return model.CheckoutResult{}, e
|
||||
}
|
||||
branch, _ := g.run(ctx, dir, "branch", "--show-current")
|
||||
return model.CheckoutResult{Branch: strings.TrimSpace(branch), Message: "分支切换成功"}, nil
|
||||
}
|
||||
|
||||
func parseLog(log string, out *model.GitStats) {
|
||||
var cur *model.GitCommit
|
||||
for _, line := range strings.Split(log, "\n") {
|
||||
if strings.HasPrefix(line, "@@CC@@") {
|
||||
if cur != nil {
|
||||
out.Commits = append(out.Commits, *cur)
|
||||
}
|
||||
p := strings.Split(strings.TrimPrefix(line, "@@CC@@"), "\x1f")
|
||||
if len(p) == 5 {
|
||||
cur = &model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]}
|
||||
} else {
|
||||
cur = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur != nil {
|
||||
p := strings.Split(line, "\t")
|
||||
if len(p) >= 2 {
|
||||
a, e1 := strconv.ParseInt(p[0], 10, 64)
|
||||
d, e2 := strconv.ParseInt(p[1], 10, 64)
|
||||
if e1 == nil {
|
||||
cur.Added += a
|
||||
}
|
||||
if e2 == nil {
|
||||
cur.Deleted += d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur != nil {
|
||||
out.Commits = append(out.Commits, *cur)
|
||||
}
|
||||
cm := map[string]*model.Contributor{}
|
||||
hm := map[string]int64{}
|
||||
cut := time.Now().AddDate(-1, 0, 0)
|
||||
for _, c := range out.Commits {
|
||||
out.Added += c.Added
|
||||
out.Deleted += c.Deleted
|
||||
x := cm[c.Email]
|
||||
if x == nil {
|
||||
x = &model.Contributor{Name: c.Author, Email: c.Email}
|
||||
cm[c.Email] = x
|
||||
}
|
||||
x.Commits++
|
||||
x.Added += c.Added
|
||||
x.Deleted += c.Deleted
|
||||
if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) {
|
||||
hm[t.Local().Format("2006-01-02")]++
|
||||
}
|
||||
}
|
||||
out.CommitCount = int64(len(out.Commits))
|
||||
for _, x := range cm {
|
||||
out.Contributors = append(out.Contributors, *x)
|
||||
}
|
||||
out.ContributorCount = int64(len(out.Contributors))
|
||||
sort.Slice(out.Contributors, func(i, j int) bool { return out.Contributors[i].Commits > out.Contributors[j].Commits })
|
||||
for d, c := range hm {
|
||||
out.Heatmap = append(out.Heatmap, model.HeatDay{Date: d, Count: c})
|
||||
}
|
||||
sort.Slice(out.Heatmap, func(i, j int) bool { return out.Heatmap[i].Date < out.Heatmap[j].Date })
|
||||
}
|
||||
|
||||
func ShortHash(s string) string {
|
||||
if len(s) > 7 {
|
||||
return s[:7]
|
||||
}
|
||||
return s
|
||||
}
|
||||
201
service/insights.go
Normal file
201
service/insights.go
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
198
service/scanner.go
Normal file
198
service/scanner.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Package service 实现代码扫描、Git 分析和后台任务等业务能力。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/hhatto/gocloc"
|
||||
gitignore "github.com/sabhiram/go-gitignore"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"view/model"
|
||||
"view/platform"
|
||||
)
|
||||
|
||||
// Scanner 负责遍历项目、应用排除规则并调用 gocloc 统计代码。它不依赖 Wails,可独立测试。
|
||||
type Scanner struct{}
|
||||
|
||||
// MatchExcludedPattern 对目录的每一级名称应用通配符,并对带斜杠的规则匹配完整相对路径。
|
||||
func MatchExcludedPattern(pattern, rel string) bool {
|
||||
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
|
||||
rel = filepath.ToSlash(rel)
|
||||
if strings.Contains(pattern, "/") {
|
||||
ok, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(rel))
|
||||
return ok
|
||||
}
|
||||
for _, part := range strings.Split(rel, "/") {
|
||||
if ok, _ := filepath.Match(pattern, part); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Analyze 同时支持普通 Windows 路径与 WSL UNC 路径。WSL 文件通过 UNC 读取,Git 则由 GitService 在发行版内部执行。
|
||||
func (Scanner) Analyze(ctx context.Context, root string, rules []model.ExclusionRule, progress func(int, string)) ([]model.LanguageStat, []model.FileEntry, error) {
|
||||
desc, e := platform.ResolvePath(root)
|
||||
if e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
root = desc.WindowsPath
|
||||
// WalkDir 对根目录回调错误若被忽略会得到“0 文件但成功”的假结果,因此分析前必须验证根目录。
|
||||
if info, statErr := os.Stat(root); statErr != nil {
|
||||
if desc.WSL {
|
||||
return nil, nil, &platform.PlatformError{Code: "WSL_PATH_UNAVAILABLE", Detail: statErr.Error()}
|
||||
}
|
||||
return nil, nil, statErr
|
||||
} else if !info.IsDir() {
|
||||
return nil, nil, errors.New("PROJECT_PATH_NOT_DIRECTORY")
|
||||
}
|
||||
// 每份 .gitignore 只控制其所在目录及后代。不能预先收集后再全局匹配,
|
||||
// 否则 vendor 等依赖目录中的局部通配符会错误排除项目根目录的源码。
|
||||
type scopedIgnore struct {
|
||||
base string
|
||||
matcher *gitignore.GitIgnore
|
||||
}
|
||||
ignores := []scopedIgnore{}
|
||||
addIgnore := func(dir, base string) {
|
||||
matcher, compileErr := gitignore.CompileIgnoreFile(filepath.Join(dir, ".gitignore"))
|
||||
if compileErr == nil {
|
||||
ignores = append(ignores, scopedIgnore{base: filepath.ToSlash(base), matcher: matcher})
|
||||
}
|
||||
}
|
||||
excluded := func(rel string) bool {
|
||||
slash := filepath.ToSlash(rel)
|
||||
for _, r := range rules {
|
||||
if MatchExcludedPattern(r.Pattern, slash) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, scoped := range ignores {
|
||||
local := slash
|
||||
if scoped.base != "." && scoped.base != "" {
|
||||
prefix := strings.TrimSuffix(scoped.base, "/") + "/"
|
||||
if !strings.HasPrefix(slash, prefix) {
|
||||
continue
|
||||
}
|
||||
local = strings.TrimPrefix(slash, prefix)
|
||||
}
|
||||
if scoped.matcher.MatchesPath(local) || scoped.matcher.MatchesPath("/"+local) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
files := []model.FileEntry{}
|
||||
codeFiles := []string{}
|
||||
count := 0
|
||||
e = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if path == root {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if path == root {
|
||||
addIgnore(root, ".")
|
||||
return nil
|
||||
}
|
||||
rel, x := filepath.Rel(root, path)
|
||||
if x != nil {
|
||||
return nil
|
||||
}
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if excluded(rel) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
addIgnore(path, rel)
|
||||
}
|
||||
info, x := d.Info()
|
||||
if x != nil {
|
||||
return nil
|
||||
}
|
||||
entry := model.FileEntry{Path: filepath.ToSlash(rel), Name: d.Name(), Extension: strings.ToLower(filepath.Ext(d.Name())), Size: info.Size(), IsDir: d.IsDir(), Parent: filepath.ToSlash(filepath.Dir(rel))}
|
||||
files = append(files, entry)
|
||||
if !d.IsDir() {
|
||||
count++
|
||||
ext := strings.TrimPrefix(entry.Extension, ".")
|
||||
if _, ok := gocloc.Exts[ext]; ok {
|
||||
codeFiles = append(codeFiles, path)
|
||||
}
|
||||
if count%250 == 0 {
|
||||
progress(min(65, 10+count/50), "task.scan")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
progress(70, "task.count")
|
||||
defs := gocloc.NewDefinedLanguages()
|
||||
opts := gocloc.NewClocOptions()
|
||||
totals := map[string]*model.LanguageStat{}
|
||||
for i, path := range codeFiles {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
|
||||
name := gocloc.Exts[ext]
|
||||
lang := defs.Langs[name]
|
||||
if lang == nil {
|
||||
continue
|
||||
}
|
||||
cf, parseErr := analyzeFileSafe(path, lang, opts)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
x := totals[name]
|
||||
if x == nil {
|
||||
x = &model.LanguageStat{Name: name}
|
||||
totals[name] = x
|
||||
}
|
||||
x.Files++
|
||||
x.Code += int64(cf.Code)
|
||||
x.Comments += int64(cf.Comments)
|
||||
x.Blanks += int64(cf.Blanks)
|
||||
if i%100 == 0 {
|
||||
progress(70+min(25, i*25/max(1, len(codeFiles))), "task.count")
|
||||
}
|
||||
}
|
||||
langs := make([]model.LanguageStat, 0, len(totals))
|
||||
for _, x := range totals {
|
||||
langs = append(langs, *x)
|
||||
}
|
||||
sort.Slice(langs, func(i, j int) bool { return langs[i].Code > langs[j].Code })
|
||||
progress(98, "task.save")
|
||||
return langs, files, nil
|
||||
}
|
||||
|
||||
// analyzeFileSafe 隔离第三方语言解析器的单文件异常。某个特殊源码不能影响整个桌面进程和其他文件统计。
|
||||
func analyzeFileSafe(path string, lang *gocloc.Language, opts *gocloc.ClocOptions) (result *gocloc.ClocFile, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("CODE_FILE_PARSE_PANIC: %s: %v", path, r)
|
||||
}
|
||||
}()
|
||||
return gocloc.AnalyzeFile(path, lang, opts), nil
|
||||
}
|
||||
Reference in New Issue
Block a user