296 lines
7.3 KiB
Go
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])
|
|
}
|