Files
Agent-System/internal/squad/quality_gate_rdev.go
T

296 lines
7.3 KiB
Go

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