package project import ( "fmt" "regexp" "strings" "cyberstrike-ai/internal/database" ) var ( bodyDepFactLine = regexp.MustCompile(`(?im)^[\s\-*]*依赖事实\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`) bodyRelFactLine = regexp.MustCompile(`(?im)^[\s\-*]*相关\s*fact_key\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`) bodyAssocSection = regexp.MustCompile(`(?im)^##\s*关联\s*$`) bodySyncLinksHead = "结构化关系边(自动同步)" ) // ParseLinksFromBody 从 body「关联」段落解析 from 语义的关系边(无显式 links 时的兜底)。 func ParseLinksFromBody(body string) []database.ProjectFactEdgeFromInput { body = strings.TrimSpace(body) if body == "" { return nil } seen := map[string]struct{}{} var out []database.ProjectFactEdgeFromInput add := func(key, edgeType string) { key = strings.TrimSpace(key) if key == "" { return } if err := database.ValidateFactKey(key); err != nil { return } sig := edgeType + "\x00" + key if _, ok := seen[sig]; ok { return } seen[sig] = struct{}{} out = append(out, database.ProjectFactEdgeFromInput{From: key, Type: edgeType}) } for _, m := range bodyDepFactLine.FindAllStringSubmatch(body, -1) { if len(m) > 1 { add(m[1], "depends_on") } } for _, m := range bodyRelFactLine.FindAllStringSubmatch(body, -1) { if len(m) > 1 { add(m[1], "supports") } } // 自动同步块:type: key syncBlock := extractBodySyncLinksBlock(body) for _, line := range strings.Split(syncBlock, "\n") { line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-")) if line == "" { continue } edgeType, source, ok := strings.Cut(line, ":") if !ok { continue } edgeType = strings.TrimSpace(edgeType) source = strings.TrimSpace(source) if err := database.ValidateProjectFactEdgeType(edgeType); err != nil { continue } add(source, edgeType) } if len(out) == 0 { return nil } return out } func extractBodySyncLinksBlock(body string) string { lines := strings.Split(body, "\n") var b strings.Builder inAssoc := false inSync := false for _, line := range lines { trim := strings.TrimSpace(line) if bodyAssocSection.MatchString(trim) { inAssoc = true inSync = false continue } if inAssoc && strings.HasPrefix(trim, "## ") && !strings.HasPrefix(trim, "## 关联") { break } if inAssoc && strings.Contains(trim, bodySyncLinksHead) { inSync = true continue } if inSync { if trim == "" || strings.HasPrefix(trim, "-") || strings.Contains(trim, ":") { if strings.HasPrefix(trim, "-") || (strings.Contains(trim, ":") && !strings.Contains(trim, "related_vulnerability")) { b.WriteString(trim) b.WriteByte('\n') } } else if strings.HasPrefix(trim, "##") { break } } } return b.String() } // SyncBodyLinksSection 将入边镜像写入 body 的「关联」段(人读用;结构化以 links 为准)。 func SyncBodyLinksSection(body string, edges []*database.ProjectFactEdge) string { body = strings.TrimSpace(body) block := formatBodySyncLinksBlock(edges) if block == "" { return body } if body == "" { return "## 关联\n" + block } lines := strings.Split(body, "\n") var out []string inAssoc := false replaced := false for i := 0; i < len(lines); i++ { trim := strings.TrimSpace(lines[i]) if bodyAssocSection.MatchString(trim) { inAssoc = true out = append(out, lines[i]) // 跳过旧同步块 j := i + 1 for j < len(lines) { t := strings.TrimSpace(lines[j]) if strings.HasPrefix(t, "## ") { break } if strings.Contains(t, bodySyncLinksHead) { for j < len(lines) { t2 := strings.TrimSpace(lines[j]) if t2 != "" && !strings.HasPrefix(t2, "-") && !strings.Contains(t2, ":") && !strings.Contains(t2, bodySyncLinksHead) { if strings.HasPrefix(t2, "##") { break } } j++ if j < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[j]), "## ") { break } if j >= len(lines) { break } if j > i+1 && strings.TrimSpace(lines[j-1]) == "" && strings.HasPrefix(strings.TrimSpace(lines[j]), "## ") { break } } break } j++ } out = append(out, block) i = j - 1 replaced = true continue } out = append(out, lines[i]) } if !replaced { if !inAssoc { out = append(out, "", "## 关联", block) } else { out = append(out, block) } } return strings.TrimSpace(strings.Join(out, "\n")) } func formatBodySyncLinksBlock(edges []*database.ProjectFactEdge) string { if len(edges) == 0 { return fmt.Sprintf("- %s:\n (暂无)", bodySyncLinksHead) } var b strings.Builder b.WriteString("- ") b.WriteString(bodySyncLinksHead) b.WriteString(":\n") for _, e := range edges { b.WriteString(fmt.Sprintf(" - %s: %s\n", e.EdgeType, e.SourceFactKey)) } return strings.TrimRight(b.String(), "\n") } // ResolveFactLinksForUpsert 合并显式 links、links_text 与 body 解析结果。 func ResolveFactLinksForUpsert(explicit []database.ProjectFactEdgeFromInput, linksText *string, body string, explicitSet bool) ([]database.ProjectFactEdgeFromInput, bool, error) { if explicitSet { if len(explicit) > 0 { return explicit, true, nil } if linksText != nil { parsed, err := ParseFactLinksText(*linksText) if err != nil { return nil, true, err } if parsed == nil { return []database.ProjectFactEdgeFromInput{}, true, nil } return parsed, true, nil } return []database.ProjectFactEdgeFromInput{}, true, nil } if parsed := ParseLinksFromBody(body); len(parsed) > 0 { return parsed, true, nil } return nil, false, nil } // MergeLinkFromInputsUnique 合并多组 from 入边输入并去重。 func MergeLinkFromInputsUnique(groups ...[]database.ProjectFactEdgeFromInput) []database.ProjectFactEdgeFromInput { seen := map[string]struct{}{} var out []database.ProjectFactEdgeFromInput for _, g := range groups { for _, in := range g { sig := in.Type + "\x00" + in.From if _, ok := seen[sig]; ok { continue } if err := database.ValidateProjectFactEdgeType(in.Type); err != nil { continue } if err := database.ValidateFactKey(in.From); err != nil { continue } seen[sig] = struct{}{} out = append(out, in) } } return out } // MergeLinkInputsUnique 合并多组 link 输入并去重(内部出边写入用)。 func MergeLinkInputsUnique(groups ...[]database.ProjectFactEdgeInput) []database.ProjectFactEdgeInput { seen := map[string]struct{}{} var out []database.ProjectFactEdgeInput for _, g := range groups { for _, in := range g { sig := in.Type + "\x00" + in.To if _, ok := seen[sig]; ok { continue } if err := database.ValidateProjectFactEdgeType(in.Type); err != nil { continue } if err := database.ValidateFactKey(in.To); err != nil { continue } seen[sig] = struct{}{} out = append(out, in) } } return out }