package squad import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" ) var rdevReqIDPattern = regexp.MustCompile(`\b(UR|RS)-(\d+)\b`) // rdProposalRequiredHeadings are the professional ## titles from templates/docs/rd_proposal.md. // Exact cleaned match is required; fuzzy Contains is not enough for title alignment. var rdProposalRequiredHeadings = []string{ "项目背景与立项依据", "产品定义与范围", "目标、里程碑与资源假设", "主要风险与对策概览", } func rdevDeterministicViolations(wsDir, taskKey, content string) []string { meta, ok := LookupRDevTask(taskKey) if !ok { return nil } switch taskKey { case "rd_proposal": return rdevProposalHeadingIssues(content) case "rd_user_needs": return rdevRequirementIssues(wsDir, content, "UR") case "rd_system_reqs": return rdevRequirementIssues(wsDir, content, "RS") } switch meta.Kind { case RDevKindReviewMinutes: return rdevReviewMinutesIssues(content) case RDevKindUploadIndex: return rdevUploadIndexIssues(content) default: return nil } } func rdevProposalHeadingIssues(content string) []string { // Require literal "## 标题" lines (no numbering / engineer-suffix variants). var issues []string for _, want := range rdProposalRequiredHeadings { exact := "## " + want found := false for _, line := range strings.Split(content, "\n") { if strings.TrimSpace(line) == exact { found = true break } } if !found { issues = append(issues, "建议书章节标题未对齐模板: "+want) } } return issues } func rdevRequirementIssues(wsDir, content, prefix string) []string { var issues []string if strings.Contains(content, "随机抽取") { issues = append(issues, "需求集合不得以随机抽取代替全量编号") } ids := orderedRequirementIDs(content, prefix) if len(ids) == 0 { issues = append(issues, "缺少连续需求编号 "+prefix+"-01") } else if gap := consecutiveIDGap(ids); gap != "" { issues = append(issues, gap) } raw, err := os.ReadFile(filepath.Join(wsDir, "artifacts", "requirements.json")) if err != nil { issues = append(issues, "requirements.json 为必需产物") return issues } var reqs []map[string]any if json.Unmarshal(raw, &reqs) != nil { issues = append(issues, "requirements.json 格式无效") return issues } jsonIDs := map[string]bool{} for _, req := range reqs { id, _ := req["id"].(string) id = strings.TrimSpace(id) if id == "" { issues = append(issues, "需求缺少 id") continue } if jsonIDs[id] { issues = append(issues, "重复需求 id: "+id) } jsonIDs[id] = true method, _ := req["verification_method"].(string) criteria, _ := req["acceptance_criteria"].(string) if strings.TrimSpace(method) == "" && strings.TrimSpace(criteria) == "" { issues = append(issues, id+": 缺少验证准则") } } bodyIDs := map[string]bool{} for _, id := range ids { bodyIDs[id] = true if !jsonIDs[id] { issues = append(issues, "正文有而 JSON 没有: "+id) } } for id := range jsonIDs { if !bodyIDs[id] { issues = append(issues, "JSON 有而正文没有: "+id) } } return issues } func orderedRequirementIDs(content, prefix string) []string { seen := map[string]bool{} var ids []string for _, match := range rdevReqIDPattern.FindAllStringSubmatch(content, -1) { if match[1] != prefix { continue } n, err := strconv.Atoi(match[2]) if err != nil { continue } id := fmt.Sprintf("%s-%02d", prefix, n) if seen[id] { continue } seen[id] = true ids = append(ids, id) } return ids } func consecutiveIDGap(ids []string) string { nums := make([]int, 0, len(ids)) seen := map[int]bool{} for _, id := range ids { part := id[strings.LastIndex(id, "-")+1:] n, err := strconv.Atoi(part) if err != nil { return "需求编号无法解析: " + id } if seen[n] { return "重复需求编号: " + id } seen[n] = true nums = append(nums, n) } for _, n := range nums { if n < 1 || n > len(nums) || !seen[n] { return fmt.Sprintf("需求编号必须从 01 连续到 %02d", len(nums)) } } for n := 1; n <= len(nums); n++ { if !seen[n] { return fmt.Sprintf("需求编号不连续,缺少 %02d", n) } } return "" } func rdevReviewMinutesIssues(content string) []string { var issues []string section := sectionAfterHeading(content, "## 问题与决议") headers, rows := markdownTable(section) if len(headers) == 0 { issues = append(issues, "评审记录缺少问题与决议表") return issues } col := headerIndex(headers) if len(rows) == 0 { issues = append(issues, "评审记录没有问题行") } hollow := strings.Contains(content, "同意通过") substantive := false for _, row := range rows { problem := cell(row, col["问题"]) decision := cell(row, col["决议"]) owner := cell(row, col["责任人"]) due := cell(row, col["期限"]) if problem == "" || decision == "" || owner == "" || due == "" { issues = append(issues, "问题行缺少问题、决议、责任人或期限") continue } if problem != "无" && problem != "同意通过" { substantive = true } } if hollow && !substantive { issues = append(issues, "评审记录只有空洞「同意通过」,缺少实质问题") } action := sectionAfterHeading(content, "## 遗留行动项跟踪") _, actionRows := markdownTable(action) if len(actionRows) == 0 { issues = append(issues, "评审记录缺少可跟踪行动项") } return issues } func rdevUploadIndexIssues(content string) []string { var issues []string if _, rows := markdownTable(content); len(rows) == 0 { issues = append(issues, "附件清单缺少索引表") } if !strings.Contains(content, "影响") { issues = append(issues, "附件清单缺少缺失影响分析") } return issues } func sectionAfterHeading(content, heading string) string { idx := strings.Index(content, heading) if idx < 0 { return "" } rest := content[idx+len(heading):] if n := strings.Index(rest, "\n## "); n >= 0 { rest = rest[:n] } return rest } func markdownTable(section string) (headers []string, rows [][]string) { var header []string started := false for _, line := range strings.Split(section, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "|") { // 同一节里后面的表(例如法规对照)不属于第一张问题表。 if started { break } continue } started = true cells := splitTableRow(line) if len(cells) == 0 { continue } if isSeparatorRow(cells) { continue } if header == nil { header = cells continue } rows = append(rows, cells) } return header, rows } func splitTableRow(line string) []string { line = strings.Trim(line, "|") parts := strings.Split(line, "|") out := make([]string, 0, len(parts)) for _, p := range parts { out = append(out, strings.TrimSpace(p)) } return out } func isSeparatorRow(cells []string) bool { for _, c := range cells { c = strings.TrimSpace(strings.ReplaceAll(c, ":", "")) if c == "" { continue } if strings.Trim(c, "-") != "" { return false } } return true } func headerIndex(headers []string) map[string]int { idx := map[string]int{"问题": -1, "决议": -1, "责任人": -1, "期限": -1} for i, h := range headers { idx[h] = i } return idx } func cell(row []string, i int) string { if i < 0 || i >= len(row) { return "" } return strings.TrimSpace(row[i]) }