feat(workflow): 新增依赖风险审计命令
This commit is contained in:
parent
52b7093846
commit
ec2cfb69e4
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 直接调用:
|
||||
|
|
|
|||
|
|
@ -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 注册。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
newDependencyAuditShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue