From ec2cfb69e426674e4206205525268fcdf3e5b7e1 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 10 Jun 2026 14:57:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E6=96=B0=E5=A2=9E=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E9=A3=8E=E9=99=A9=E5=AE=A1=E8=AE=A1=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 + README.zh-CN.md | 20 + doc/changes/workflow-dependency-audit.md | 7 + shortcuts/workflow/dependency_audit.go | 603 ++++++++++++++++++++ shortcuts/workflow/dependency_audit_test.go | 150 +++++ shortcuts/workflow/workflow.go | 1 + 6 files changed, 790 insertions(+) create mode 100644 doc/changes/workflow-dependency-audit.md create mode 100644 shortcuts/workflow/dependency_audit.go create mode 100644 shortcuts/workflow/dependency_audit_test.go diff --git a/README.md b/README.md index b43dcd1..44de506 100644 --- a/README.md +++ b/README.md @@ -485,9 +485,11 @@ gitlink-cli search +users -k "zhangsan" - `workflow +health` - `workflow +pr-summary` - `workflow +repo-report` +- `workflow +dependency-audit` `workflow +pr-summary` defaults to `table` when `--format` is omitted. `workflow +repo-report` defaults to `markdown` when `--format` is omitted. +`workflow +dependency-audit` defaults to `table` when `--format` is omitted. Examples: @@ -560,6 +562,12 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma # Repository workflow report from a local JSON file gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json + +# Audit go.mod dependency risk signals without network access +gitlink-cli workflow +dependency-audit --from go.mod --format table + +# Render dependency audit findings as markdown +gitlink-cli workflow +dependency-audit --repository Gitlink/gitlink-cli --from go.mod --format markdown ``` Output formats: @@ -575,6 +583,7 @@ Safety: - They do not depend on LLM APIs. - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. +- `workflow +dependency-audit` reads local go.mod or JSON input only; it does not download modules or contact remote services. ### Raw API diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..ae40d7d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -455,6 +455,26 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### 工作流命令 + +`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和依赖风险审计。 + +```bash +# 生成单个 PR 的审查摘要 +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown + +# 生成仓库工作流报告 +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown + +# 审计 go.mod 中的依赖风险信号 +gitlink-cli workflow +dependency-audit --from go.mod --format table + +# 输出 markdown 格式的依赖审计报告 +gitlink-cli workflow +dependency-audit --repository Gitlink/gitlink-cli --from go.mod --format markdown +``` + +`workflow +dependency-audit` 默认使用 `table` 输出,会检查本地 replace、pseudo version、预发布版本、主版本路径不匹配、go 指令缺失或过旧等风险;命令只读取本地 `go.mod` 或 JSON 输入,不会下载模块或访问远端服务。 + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: diff --git a/doc/changes/workflow-dependency-audit.md b/doc/changes/workflow-dependency-audit.md new file mode 100644 index 0000000..f518f75 --- /dev/null +++ b/doc/changes/workflow-dependency-audit.md @@ -0,0 +1,7 @@ +# 新增依赖风险审计工作流 + +`gitlink-cli workflow +dependency-audit` 新增了面向 Go 项目的本地依赖风险审计能力。命令可以直接读取 `go.mod`,也可以读取结构化 JSON 输入,输出 `table`、`markdown` 或 `json` 报告,便于维护者在合并前快速判断依赖变更是否需要重点复核。 + +审计规则保持确定性和可复现,不联网查询版本信息,而是专注于 go.mod 中已经存在的风险信号:本地 `replace`、pseudo version、预发布版本、语义化主版本和模块路径不匹配、缺失或过旧的 `go` 指令等。报告会给出风险等级、扣分后的健康分、finding 列表和处理建议,适合放进 PR 审查、发布前检查或自动化脚本。 + +本次变更包含命令注册、go.mod/JSON 输入解析、风险评分、中文和英文输出、README 示例、中文 README 说明以及单元测试。测试覆盖了 go.mod 块解析、JSON 输入、本地 replace、pseudo version、主版本不匹配、预发布版本、markdown/table 渲染和 shortcut 注册。 diff --git a/shortcuts/workflow/dependency_audit.go b/shortcuts/workflow/dependency_audit.go new file mode 100644 index 0000000..64014f3 --- /dev/null +++ b/shortcuts/workflow/dependency_audit.go @@ -0,0 +1,603 @@ +package workflow + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "text/tabwriter" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type DependencyAuditInput struct { + Repository string `json:"repository,omitempty"` + Module string `json:"module"` + GoVersion string `json:"go_version,omitempty"` + Toolchain string `json:"toolchain,omitempty"` + Requirements []DependencyRequirement `json:"requirements"` + Replacements []DependencyReplacement `json:"replacements,omitempty"` + Source string `json:"source,omitempty"` +} + +type DependencyRequirement struct { + Path string `json:"path"` + Version string `json:"version"` + Indirect bool `json:"indirect,omitempty"` +} + +type DependencyReplacement struct { + OldPath string `json:"old_path"` + OldVersion string `json:"old_version,omitempty"` + NewPath string `json:"new_path"` + NewVersion string `json:"new_version,omitempty"` +} + +type DependencyAuditResult struct { + Repository string `json:"repository"` + Module string `json:"module"` + GoVersion string `json:"go_version,omitempty"` + Toolchain string `json:"toolchain,omitempty"` + TotalDependencies int `json:"total_dependencies"` + DirectDependencies int `json:"direct_dependencies"` + IndirectDependencies int `json:"indirect_dependencies"` + ReplacementCount int `json:"replacement_count"` + PseudoVersionCount int `json:"pseudo_version_count"` + LocalReplacementCount int `json:"local_replacement_count"` + MajorMismatchCount int `json:"major_mismatch_count"` + PreReleaseCount int `json:"pre_release_count"` + Score int `json:"score"` + RiskLevel string `json:"risk_level"` + Findings []DependencyAuditFinding `json:"findings"` + Recommendations []string `json:"recommendations"` + Source string `json:"source"` +} + +type DependencyAuditFinding struct { + Severity string `json:"severity"` + Code string `json:"code"` + Dependency string `json:"dependency,omitempty"` + Message string `json:"message"` +} + +func newDependencyAuditShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "dependency-audit", + Description: "Audit go.mod dependency risk signals without network access", + Flags: []common.Flag{ + {Name: "from", Usage: "Read go.mod or DependencyAuditInput JSON from a local file", Default: "go.mod"}, + {Name: "repository", Usage: "Repository name, for example owner/repo"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, + }, + Run: runDependencyAudit, + } +} + +func runDependencyAudit(ctx *common.RuntimeContext) error { + input, err := collectDependencyAuditInput(ctx) + if err != nil { + return err + } + lang := normalizeLang(ctx.Arg("lang")) + result := AnalyzeDependencyAudit(input, lang) + format := ctx.Format + if strings.TrimSpace(cmdutil.Format) == "" { + format = "table" + } + rendered, err := RenderDependencyAudit(result, format, lang) + if err != nil { + return err + } + _, err = fmt.Fprint(os.Stdout, rendered) + return err +} + +func collectDependencyAuditInput(ctx *common.RuntimeContext) (DependencyAuditInput, error) { + path := strings.TrimSpace(ctx.Arg("from")) + if path == "" { + path = "go.mod" + } + input, err := readDependencyAuditInput(path) + if err != nil { + return DependencyAuditInput{}, err + } + if repo := strings.TrimSpace(ctx.Arg("repository")); repo != "" { + input.Repository = repo + } + if input.Repository == "" { + input.Repository = repositoryFromContext(ctx, "") + } + if input.Source == "" { + input.Source = path + } + return input, nil +} + +func readDependencyAuditInput(path string) (DependencyAuditInput, error) { + data, err := os.ReadFile(path) + if err != nil { + return DependencyAuditInput{}, fmt.Errorf("read dependency audit input: %w", err) + } + trimmed := bytes.TrimSpace(data) + if len(trimmed) > 0 && trimmed[0] == '{' { + var input DependencyAuditInput + if err := json.Unmarshal(trimmed, &input); err != nil { + return DependencyAuditInput{}, fmt.Errorf("parse dependency audit JSON: %w", err) + } + if strings.TrimSpace(input.Module) == "" { + return DependencyAuditInput{}, fmt.Errorf("parse dependency audit JSON: module is required") + } + if input.Source == "" { + input.Source = path + } + return input, nil + } + input, err := ParseGoModForDependencyAudit(string(data)) + if err != nil { + return DependencyAuditInput{}, err + } + input.Source = path + return input, nil +} + +func ParseGoModForDependencyAudit(content string) (DependencyAuditInput, error) { + scanner := bufio.NewScanner(strings.NewReader(content)) + input := DependencyAuditInput{} + inRequireBlock := false + inReplaceBlock := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if inRequireBlock { + if line == ")" { + inRequireBlock = false + continue + } + if req, ok := parseGoModRequirement(line); ok { + input.Requirements = append(input.Requirements, req) + } + continue + } + if inReplaceBlock { + if line == ")" { + inReplaceBlock = false + continue + } + if repl, ok := parseGoModReplacement(line); ok { + input.Replacements = append(input.Replacements, repl) + } + continue + } + switch { + case line == "require (": + inRequireBlock = true + case line == "replace (": + inReplaceBlock = true + case strings.HasPrefix(line, "module "): + input.Module = strings.TrimSpace(strings.TrimPrefix(line, "module ")) + case strings.HasPrefix(line, "go "): + input.GoVersion = strings.TrimSpace(strings.TrimPrefix(line, "go ")) + case strings.HasPrefix(line, "toolchain "): + input.Toolchain = strings.TrimSpace(strings.TrimPrefix(line, "toolchain ")) + case strings.HasPrefix(line, "require "): + if req, ok := parseGoModRequirement(strings.TrimSpace(strings.TrimPrefix(line, "require "))); ok { + input.Requirements = append(input.Requirements, req) + } + case strings.HasPrefix(line, "replace "): + if repl, ok := parseGoModReplacement(strings.TrimSpace(strings.TrimPrefix(line, "replace "))); ok { + input.Replacements = append(input.Replacements, repl) + } + } + } + if err := scanner.Err(); err != nil { + return DependencyAuditInput{}, fmt.Errorf("scan go.mod: %w", err) + } + if strings.TrimSpace(input.Module) == "" { + return DependencyAuditInput{}, fmt.Errorf("parse go.mod: module directive is required") + } + return input, nil +} + +func parseGoModRequirement(line string) (DependencyRequirement, bool) { + withoutComment, comment := splitGoModComment(line) + fields := strings.Fields(withoutComment) + if len(fields) < 2 { + return DependencyRequirement{}, false + } + return DependencyRequirement{ + Path: fields[0], + Version: fields[1], + Indirect: strings.Contains(comment, "indirect"), + }, true +} + +func parseGoModReplacement(line string) (DependencyReplacement, bool) { + withoutComment, _ := splitGoModComment(line) + parts := strings.Split(withoutComment, "=>") + if len(parts) != 2 { + return DependencyReplacement{}, false + } + oldFields := strings.Fields(strings.TrimSpace(parts[0])) + newFields := strings.Fields(strings.TrimSpace(parts[1])) + if len(oldFields) == 0 || len(newFields) == 0 { + return DependencyReplacement{}, false + } + repl := DependencyReplacement{OldPath: oldFields[0], NewPath: newFields[0]} + if len(oldFields) > 1 { + repl.OldVersion = oldFields[1] + } + if len(newFields) > 1 { + repl.NewVersion = newFields[1] + } + return repl, true +} + +func splitGoModComment(line string) (string, string) { + idx := strings.Index(line, "//") + if idx < 0 { + return strings.TrimSpace(line), "" + } + return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+2:]) +} + +func AnalyzeDependencyAudit(input DependencyAuditInput, lang string) DependencyAuditResult { + lang = normalizeLang(lang) + result := DependencyAuditResult{ + Repository: strings.TrimSpace(input.Repository), + Module: strings.TrimSpace(input.Module), + GoVersion: strings.TrimSpace(input.GoVersion), + Toolchain: strings.TrimSpace(input.Toolchain), + TotalDependencies: len(input.Requirements), + ReplacementCount: len(input.Replacements), + Source: strings.TrimSpace(input.Source), + } + if result.Repository == "" { + result.Repository = "local" + } + if result.Source == "" { + result.Source = "local" + } + + for _, req := range input.Requirements { + if req.Indirect { + result.IndirectDependencies++ + } else { + result.DirectDependencies++ + } + if isPseudoVersion(req.Version) { + result.PseudoVersionCount++ + result.Findings = append(result.Findings, dependencyFinding(lang, "medium", "pseudo_version", req.Path, "dependency uses a pseudo version; pin a tagged release when possible")) + } + if isPreReleaseVersion(req.Version) { + result.PreReleaseCount++ + result.Findings = append(result.Findings, dependencyFinding(lang, "low", "pre_release", req.Path, "dependency uses a pre-release version; verify it is intentional")) + } + if hasMajorVersionMismatch(req.Path, req.Version) { + result.MajorMismatchCount++ + result.Findings = append(result.Findings, dependencyFinding(lang, "high", "major_version_mismatch", req.Path, "module path and semantic major version do not match")) + } + } + for _, repl := range input.Replacements { + dep := repl.OldPath + if dep == "" { + dep = repl.NewPath + } + switch { + case isLocalReplacement(repl.NewPath): + result.LocalReplacementCount++ + result.Findings = append(result.Findings, dependencyFinding(lang, "high", "local_replace", dep, "local replace directive can break clean builds outside this checkout")) + case repl.NewVersion == "" && !looksLikeModulePath(repl.NewPath): + result.Findings = append(result.Findings, dependencyFinding(lang, "medium", "replace_without_version", dep, "replace directive has no target version")) + default: + result.Findings = append(result.Findings, dependencyFinding(lang, "low", "replace_directive", dep, "replace directive should be reviewed before release")) + } + } + if result.GoVersion == "" { + result.Findings = append(result.Findings, dependencyFinding(lang, "high", "missing_go_version", result.Module, "go directive is missing")) + } else if isOldGoVersion(result.GoVersion) { + result.Findings = append(result.Findings, dependencyFinding(lang, "medium", "old_go_version", result.Module, "go directive is older than 1.20; verify supported toolchains")) + } + sort.SliceStable(result.Findings, func(i, j int) bool { + return dependencySeverityWeight(result.Findings[i].Severity) > dependencySeverityWeight(result.Findings[j].Severity) + }) + result.Score = scoreDependencyAudit(result.Findings) + result.RiskLevel = dependencyRiskLevel(result.Findings) + result.Recommendations = dependencyAuditRecommendations(result, lang) + return result +} + +func dependencyFinding(lang, severity, code, dependency, message string) DependencyAuditFinding { + if normalizeLang(lang) == langZH { + switch code { + case "pseudo_version": + message = "依赖使用 pseudo version,建议在可行时固定到正式 tag。" + case "pre_release": + message = "依赖使用预发布版本,请确认这是有意选择。" + case "major_version_mismatch": + message = "模块路径和语义化主版本不匹配。" + case "local_replace": + message = "本地 replace 可能导致其他环境无法干净构建。" + case "replace_without_version": + message = "replace 指令缺少目标版本。" + case "replace_directive": + message = "发布前需要人工确认 replace 指令。" + case "missing_go_version": + message = "go.mod 缺少 go 指令。" + case "old_go_version": + message = "go 指令低于 1.20,请确认支持的构建工具链。" + } + } + return DependencyAuditFinding{ + Severity: severity, + Code: code, + Dependency: dependency, + Message: message, + } +} + +var pseudoVersionPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+-(?:\w+\.)?0\.\d{14}-[0-9a-f]{12}$|^v\d+\.\d+\.\d+-\d{14}-[0-9a-f]{12}$`) + +func isPseudoVersion(version string) bool { + return pseudoVersionPattern.MatchString(strings.TrimSpace(version)) +} + +func isPreReleaseVersion(version string) bool { + version = strings.ToLower(strings.TrimSpace(version)) + return strings.Contains(version, "-alpha") || strings.Contains(version, "-beta") || strings.Contains(version, "-rc") || strings.Contains(version, "-dev") +} + +func hasMajorVersionMismatch(path, version string) bool { + major := semanticMajor(version) + if major < 2 { + return false + } + if strings.HasPrefix(path, "gopkg.in/") { + return !strings.Contains(path, fmt.Sprintf(".v%d", major)) + } + return !strings.HasSuffix(path, fmt.Sprintf("/v%d", major)) +} + +func semanticMajor(version string) int { + version = strings.TrimPrefix(strings.TrimSpace(version), "v") + parts := strings.Split(version, ".") + if len(parts) == 0 { + return 0 + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + return major +} + +func isLocalReplacement(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + if filepath.IsAbs(path) || strings.HasPrefix(path, ".") || strings.HasPrefix(path, `..\`) || strings.HasPrefix(path, "../") { + return true + } + return len(path) >= 2 && path[1] == ':' +} + +func looksLikeModulePath(path string) bool { + return strings.Contains(path, ".") && strings.Contains(path, "/") +} + +func isOldGoVersion(version string) bool { + parts := strings.Split(strings.TrimSpace(version), ".") + if len(parts) < 2 { + return false + } + major, errMajor := strconv.Atoi(parts[0]) + minor, errMinor := strconv.Atoi(parts[1]) + if errMajor != nil || errMinor != nil { + return false + } + return major < 1 || (major == 1 && minor < 20) +} + +func scoreDependencyAudit(findings []DependencyAuditFinding) int { + score := 100 + for _, finding := range findings { + switch finding.Severity { + case "critical": + score -= 25 + case "high": + score -= 15 + case "medium": + score -= 8 + case "low": + score -= 3 + } + } + if score < 0 { + return 0 + } + return score +} + +func dependencyRiskLevel(findings []DependencyAuditFinding) string { + for _, finding := range findings { + if finding.Severity == "critical" || finding.Severity == "high" { + return "high" + } + } + for _, finding := range findings { + if finding.Severity == "medium" { + return "medium" + } + } + if len(findings) > 0 { + return "low" + } + return "clean" +} + +func dependencySeverityWeight(severity string) int { + switch severity { + case "critical": + return 4 + case "high": + return 3 + case "medium": + return 2 + case "low": + return 1 + default: + return 0 + } +} + +func dependencyAuditRecommendations(result DependencyAuditResult, lang string) []string { + zh := normalizeLang(lang) == langZH + recs := []string{} + if result.LocalReplacementCount > 0 { + if zh { + recs = append(recs, "合并或发布前移除本地 replace,或改为可复现的远端模块版本。") + } else { + recs = append(recs, "Remove local replace directives before merge or release, or point them at reproducible module versions.") + } + } + if result.MajorMismatchCount > 0 { + if zh { + recs = append(recs, "修正主版本路径不匹配的依赖,避免 Go module 解析和升级出现异常。") + } else { + recs = append(recs, "Fix major-version path mismatches to avoid Go module resolution and upgrade surprises.") + } + } + if result.PseudoVersionCount > 0 { + if zh { + recs = append(recs, "将 pseudo version 升级到正式 tag,降低不可读提交依赖带来的维护成本。") + } else { + recs = append(recs, "Prefer tagged releases over pseudo versions to reduce maintenance risk.") + } + } + if len(recs) == 0 { + if zh { + recs = append(recs, "当前未发现明显依赖风险,保持 go.mod 简洁并在合并前运行 go mod tidy。") + } else { + recs = append(recs, "No obvious dependency risks found; keep go.mod tidy and run go mod tidy before merge.") + } + } + return recs +} + +func RenderDependencyAudit(result DependencyAuditResult, format string, lang string) (string, error) { + var buf bytes.Buffer + switch normalizeFormat(format) { + case "json": + if err := writeJSON(&buf, result); err != nil { + return "", err + } + case "markdown": + if err := writeDependencyAuditMarkdown(&buf, result, lang); err != nil { + return "", err + } + case "table": + if err := writeDependencyAuditTable(&buf, result); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported workflow output format %q", format) + } + return buf.String(), nil +} + +func writeDependencyAuditMarkdown(buf *bytes.Buffer, result DependencyAuditResult, lang string) error { + title := "Dependency Audit" + if normalizeLang(lang) == langZH { + title = "依赖风险审计" + } + if _, err := fmt.Fprintf(buf, "# %s\n\n", title); err != nil { + return err + } + if _, err := fmt.Fprintf(buf, "- Repository: `%s`\n- Module: `%s`\n- Go version: `%s`\n- Score: `%d`\n- Risk: `%s`\n- Dependencies: `%d` direct / `%d` indirect\n- Replacements: `%d`\n- Source: `%s`\n\n", + result.Repository, result.Module, emptyDash(result.GoVersion), result.Score, result.RiskLevel, result.DirectDependencies, result.IndirectDependencies, result.ReplacementCount, result.Source); err != nil { + return err + } + if _, err := fmt.Fprintln(buf, "## Findings"); err != nil { + return err + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + if len(result.Findings) == 0 { + if _, err := fmt.Fprintln(buf, "- No dependency risk findings."); err != nil { + return err + } + } else { + for _, finding := range result.Findings { + dep := finding.Dependency + if dep == "" { + dep = "-" + } + if _, err := fmt.Fprintf(buf, "- `%s` `%s` %s: %s\n", finding.Severity, finding.Code, dep, finding.Message); err != nil { + return err + } + } + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + if _, err := fmt.Fprintln(buf, "## Recommendations"); err != nil { + return err + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + for _, rec := range result.Recommendations { + if _, err := fmt.Fprintf(buf, "- %s\n", rec); err != nil { + return err + } + } + return nil +} + +func writeDependencyAuditTable(buf *bytes.Buffer, result DependencyAuditResult) error { + tw := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "MODULE\tSCORE\tRISK\tDIRECT\tINDIRECT\tREPLACE\tPSEUDO\tLOCAL_REPLACE\tMAJOR_MISMATCH"); err != nil { + return err + } + if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%d\t%d\t%d\t%d\t%d\t%d\n", + result.Module, + result.Score, + result.RiskLevel, + result.DirectDependencies, + result.IndirectDependencies, + result.ReplacementCount, + result.PseudoVersionCount, + result.LocalReplacementCount, + result.MajorMismatchCount, + ); err != nil { + return err + } + if len(result.Findings) > 0 { + if _, err := fmt.Fprintln(tw, "\nSEVERITY\tCODE\tDEPENDENCY\tMESSAGE"); err != nil { + return err + } + for _, finding := range result.Findings { + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", finding.Severity, finding.Code, emptyDash(finding.Dependency), finding.Message); err != nil { + return err + } + } + } + return tw.Flush() +} + +func emptyDash(value string) string { + if strings.TrimSpace(value) == "" { + return "-" + } + return value +} diff --git a/shortcuts/workflow/dependency_audit_test.go b/shortcuts/workflow/dependency_audit_test.go new file mode 100644 index 0000000..d069aeb --- /dev/null +++ b/shortcuts/workflow/dependency_audit_test.go @@ -0,0 +1,150 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseGoModForDependencyAudit(t *testing.T) { + input, err := ParseGoModForDependencyAudit(`module example.com/project + +go 1.21 +toolchain go1.22.1 + +require ( + github.com/acme/stable v1.2.3 + github.com/acme/pseudo v0.0.0-20240501120000-abcdef123456 // indirect + github.com/acme/major v2.1.0 +) + +replace github.com/acme/local => ../local +replace github.com/acme/fork v1.0.0 => github.com/fork/acme v1.0.1 +`) + if err != nil { + t.Fatalf("ParseGoModForDependencyAudit returned error: %v", err) + } + if input.Module != "example.com/project" || input.GoVersion != "1.21" || input.Toolchain != "go1.22.1" { + t.Fatalf("module/go/toolchain = %q/%q/%q", input.Module, input.GoVersion, input.Toolchain) + } + if len(input.Requirements) != 3 || !input.Requirements[1].Indirect { + t.Fatalf("requirements = %+v, want 3 with second indirect", input.Requirements) + } + if len(input.Replacements) != 2 || input.Replacements[0].NewPath != "../local" { + t.Fatalf("replacements = %+v, want local replacement", input.Replacements) + } +} + +func TestAnalyzeDependencyAuditFindsRiskSignals(t *testing.T) { + result := AnalyzeDependencyAudit(DependencyAuditInput{ + Repository: "owner/repo", + Module: "example.com/project", + GoVersion: "1.19", + Requirements: []DependencyRequirement{ + {Path: "github.com/acme/pseudo", Version: "v0.0.0-20240501120000-abcdef123456", Indirect: true}, + {Path: "github.com/acme/major", Version: "v2.1.0"}, + {Path: "github.com/acme/beta/v3", Version: "v3.0.0-rc.1"}, + }, + Replacements: []DependencyReplacement{ + {OldPath: "github.com/acme/local", NewPath: "./local"}, + }, + Source: "go.mod", + }, "en") + + if result.RiskLevel != "high" { + t.Fatalf("RiskLevel = %q, want high", result.RiskLevel) + } + if result.DirectDependencies != 2 || result.IndirectDependencies != 1 { + t.Fatalf("direct/indirect = %d/%d, want 2/1", result.DirectDependencies, result.IndirectDependencies) + } + if result.PseudoVersionCount != 1 || result.LocalReplacementCount != 1 || result.MajorMismatchCount != 1 || result.PreReleaseCount != 1 { + t.Fatalf("counts = pseudo:%d local:%d major:%d pre:%d", result.PseudoVersionCount, result.LocalReplacementCount, result.MajorMismatchCount, result.PreReleaseCount) + } + if !hasDependencyFinding(result.Findings, "local_replace") || !hasDependencyFinding(result.Findings, "major_version_mismatch") { + t.Fatalf("findings missing expected codes: %+v", result.Findings) + } + if result.Score >= 100 || len(result.Recommendations) == 0 { + t.Fatalf("score/recommendations = %d/%v, want risk penalty and recommendations", result.Score, result.Recommendations) + } +} + +func TestReadDependencyAuditInputSupportsJSONAndGoMod(t *testing.T) { + dir := t.TempDir() + jsonPath := filepath.Join(dir, "dependency_audit.json") + goModPath := filepath.Join(dir, "go.mod") + writeJSONFixture(t, jsonPath, DependencyAuditInput{ + Module: "example.com/json", + Requirements: []DependencyRequirement{ + {Path: "github.com/acme/lib", Version: "v1.0.0"}, + }, + }) + if err := writeTextFixture(goModPath, "module example.com/mod\n\ngo 1.21\nrequire github.com/acme/lib v1.0.0\n"); err != nil { + t.Fatalf("write go.mod fixture: %v", err) + } + + jsonInput, err := readDependencyAuditInput(jsonPath) + if err != nil { + t.Fatalf("readDependencyAuditInput(JSON) returned error: %v", err) + } + if jsonInput.Module != "example.com/json" || jsonInput.Source != jsonPath { + t.Fatalf("json input = %+v", jsonInput) + } + + goModInput, err := readDependencyAuditInput(goModPath) + if err != nil { + t.Fatalf("readDependencyAuditInput(go.mod) returned error: %v", err) + } + if goModInput.Module != "example.com/mod" || len(goModInput.Requirements) != 1 { + t.Fatalf("go.mod input = %+v", goModInput) + } +} + +func TestRenderDependencyAuditMarkdownAndTable(t *testing.T) { + result := AnalyzeDependencyAudit(DependencyAuditInput{ + Module: "example.com/project", + GoVersion: "1.21", + Requirements: []DependencyRequirement{ + {Path: "github.com/acme/lib", Version: "v1.0.0"}, + }, + }, "zh-CN") + + markdown, err := RenderDependencyAudit(result, "markdown", "zh-CN") + if err != nil { + t.Fatalf("RenderDependencyAudit markdown returned error: %v", err) + } + if !strings.Contains(markdown, "# 依赖风险审计") || !strings.Contains(markdown, "example.com/project") { + t.Fatalf("markdown output missing expected content:\n%s", markdown) + } + + table, err := RenderDependencyAudit(result, "table", "en") + if err != nil { + t.Fatalf("RenderDependencyAudit table returned error: %v", err) + } + if !strings.Contains(table, "MODULE") || !strings.Contains(table, "example.com/project") { + t.Fatalf("table output missing expected content:\n%s", table) + } +} + +func TestShortcutsExposeDependencyAudit(t *testing.T) { + names := map[string]bool{} + for _, shortcut := range Shortcuts() { + names[shortcut.Name] = true + } + if !names["dependency-audit"] { + t.Fatal("Shortcuts missing dependency-audit") + } +} + +func hasDependencyFinding(findings []DependencyAuditFinding, code string) bool { + for _, finding := range findings { + if finding.Code == code { + return true + } + } + return false +} + +func writeTextFixture(path, content string) error { + return os.WriteFile(path, []byte(content), 0600) +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index 4dc69e5..dfe131c 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), + newDependencyAuditShortcut(), } }