forked from Gitlink/gitlink-cli
Merge PR #188: Add workflow dependency risk audit
# Conflicts: # README.md # README.zh-CN.md # shortcuts/workflow/workflow.go
This commit is contained in:
commit
393105125a
14
README.md
14
README.md
|
|
@ -485,11 +485,11 @@ gitlink-cli search +users -k "zhangsan"
|
||||||
- `workflow +health`
|
- `workflow +health`
|
||||||
- `workflow +pr-summary`
|
- `workflow +pr-summary`
|
||||||
- `workflow +repo-report`
|
- `workflow +repo-report`
|
||||||
- `workflow +issue-dedupe`
|
- `workflow +dependency-audit`
|
||||||
|
|
||||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||||
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
|
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
|
||||||
`workflow +issue-dedupe` defaults to `table` when `--format` is omitted.
|
`workflow +dependency-audit` defaults to `table` when `--format` is omitted.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|
@ -563,11 +563,11 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma
|
||||||
# Repository workflow report from a local JSON file
|
# Repository workflow report from a local JSON file
|
||||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||||
|
|
||||||
# Find likely duplicate issues by read-only GitLink fetch
|
# Audit go.mod dependency risk signals without network access
|
||||||
gitlink-cli workflow +issue-dedupe --owner Gitlink --repo gitlink-cli --limit 50 --threshold 55 --format table
|
gitlink-cli workflow +dependency-audit --from go.mod --format table
|
||||||
|
|
||||||
# Find duplicate candidates from a local issue JSON file
|
# Render dependency audit findings as markdown
|
||||||
gitlink-cli workflow +issue-dedupe --from issues.json --threshold 60 --format markdown
|
gitlink-cli workflow +dependency-audit --repository Gitlink/gitlink-cli --from go.mod --format markdown
|
||||||
```
|
```
|
||||||
|
|
||||||
Output formats:
|
Output formats:
|
||||||
|
|
@ -583,7 +583,7 @@ Safety:
|
||||||
- They do not depend on LLM APIs.
|
- They do not depend on LLM APIs.
|
||||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
- `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 +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||||
- `workflow +issue-dedupe` only reports duplicate candidates; it does not close, comment on, or edit issues.
|
- `workflow +dependency-audit` reads local go.mod or JSON input only; it does not download modules or contact remote services.
|
||||||
|
|
||||||
### Raw API
|
### Raw API
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,7 @@ gitlink-cli search +users -k "zhangsan"
|
||||||
|
|
||||||
### 工作流命令
|
### 工作流命令
|
||||||
|
|
||||||
`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和重复 Issue 候选检测。
|
`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和依赖风险审计。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 生成单个 PR 的审查摘要
|
# 生成单个 PR 的审查摘要
|
||||||
|
|
@ -466,14 +466,14 @@ gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 -
|
||||||
# 生成仓库工作流报告
|
# 生成仓库工作流报告
|
||||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||||
|
|
||||||
# 从远端只读拉取 Issue 并检测重复候选
|
# 审计 go.mod 中的依赖风险信号
|
||||||
gitlink-cli workflow +issue-dedupe --owner Gitlink --repo gitlink-cli --limit 50 --threshold 55 --format table
|
gitlink-cli workflow +dependency-audit --from go.mod --format table
|
||||||
|
|
||||||
# 从本地 JSON 文件检测重复候选
|
# 输出 markdown 格式的依赖审计报告
|
||||||
gitlink-cli workflow +issue-dedupe --from issues.json --threshold 60 --format markdown
|
gitlink-cli workflow +dependency-audit --repository Gitlink/gitlink-cli --from go.mod --format markdown
|
||||||
```
|
```
|
||||||
|
|
||||||
`workflow +issue-dedupe` 默认使用 `table` 输出,会根据标题、正文和标签的共享关键词计算相似度,帮助维护者优先确认高置信重复候选;命令只输出候选对,不会关闭、评论或修改 Issue。
|
`workflow +dependency-audit` 默认使用 `table` 输出,会检查本地 replace、pseudo version、预发布版本、主版本路径不匹配、go 指令缺失或过旧等风险;命令只读取本地 `go.mod` 或 JSON 输入,不会下载模块或访问远端服务。
|
||||||
|
|
||||||
### Raw API
|
### 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,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
newHealthShortcut(),
|
newHealthShortcut(),
|
||||||
newPRSummaryShortcut(),
|
newPRSummaryShortcut(),
|
||||||
newRepoReportShortcut(),
|
newRepoReportShortcut(),
|
||||||
newIssueDedupeShortcut(),
|
newDependencyAuditShortcut(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue