feat(workflow): 新增依赖风险审计命令

This commit is contained in:
Mengz 2026-06-10 14:57:36 +08:00
parent 52b7093846
commit ec2cfb69e4
6 changed files with 790 additions and 0 deletions

View File

@ -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

View File

@ -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 直接调用:

View File

@ -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 注册。

View File

@ -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
}

View File

@ -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)
}

View File

@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
newHealthShortcut(),
newPRSummaryShortcut(),
newRepoReportShortcut(),
newDependencyAuditShortcut(),
}
}