merge: candidate quality-gate-heading-mismatch-2026-09-28-r3 into dev

This commit is contained in:
weichen
2026-09-28 05:38:01 +00:00
12 changed files with 365 additions and 22 deletions
+2
View File
@@ -6,6 +6,8 @@
| issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 |
|---|---|---|---|---|---|---|---|---|
| ISSUE-20260928-quality-heading-drift | squad / quality_gate / 缺少必填章节 / State-of-the-Art / H3 | 调研报告章节在,只因 H3 和少空格被判缺章,受控文档不投影 | high | verifying | Cursor | 2026-09-28;dev `research_1790566833205199368` manifest score 85;单测已覆盖,dev 重跑未做 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-quality-heading-drift) | [LESSON-20260928-quality-heading-normalize](../ops/lessons/2026-09-28-quality-heading-normalize.md) |
| ISSUE-20260928-data-dir-doubled | workspace / data/dev/data/dev / DataDir | 出现双层 `data/dev/data/dev/workspaces` 且只有孤立 output.md,写入点未证实 | medium | open | — | 2026-09-28;`intake_1790565513115586191` 双路径对照 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-data-dir-doubled) | 本轮不修 |
| ISSUE-20260928-task-write-verb | intent / hasTaskWriteVerb / 帮我定 / 制定 / AMBIGUOUS_FALLBACK | 「帮我定/制定」设计开发计划任务书未进入任务触发,对话只有掂量阶段模板 | medium | resolved | Cursor | 2026-09-28;test sess-p8-u33 msg 89/90;意图单测 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) |
| ISSUE-20260928-workflow-scope-reply | coordinator / ClarificationQuestion / ErrPrerequisiteNotMet / rd_design_plan | 跨工作流澄清与研发前置未满足时,原因没有进入对话 | medium | resolved | Cursor | 2026-09-28;协调器单测。test 原例停在动词未命中,未走到本分支 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) |
| ISSUE-20260928-quality-failed-success-copy | bizdev / quality_failed / 正文已保存至 / output.md | 质量未通过仍声称正文已保存,并写出工作区绝对路径 | medium | resolved | Cursor | 2026-09-28;test cdo_quote_1790560155851280170;同步回复单测 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) |
+1
View File
@@ -18,6 +18,7 @@
| owner | task | branch | worktree | mutex | status |
|---|---|---|---|---|---|
| Cursor | quality-gate-heading-mismatch-2026-09-28-r3 | fix/quality-gate-heading-mismatch-2026-09-28 | .worktrees/quality-gate-heading-mismatch-2026-09-28 | quality-gate-heading | in_progress |
| Cursor | agent-reply-llm-narrative-2026-09-28 | feature/agent-reply-llm-narrative-2026-09-28 | .worktrees/agent-reply-llm-narrative-2026-09-28 | chat+coordinator | in_progress |
| Cursor | test-user-doc-issues-2026-09-28 | fix/test-user-doc-issues-2026-09-28 | .worktrees/test-user-doc-issues-2026-09-28 | intent-write-verb | done |
| Cursor | pi-runner-env-mismatch-2026-09-28 | fix/pi-runner-env-mismatch-2026-09-28 | .worktrees/pi-runner-env-mismatch-2026-09-28 | pi-runner-env | done |
@@ -0,0 +1,20 @@
---
lesson_id: LESSON-20260928-quality-heading-normalize
lesson_state: candidate
owner: Cursor
reviewer: Cursor(自审,待 dev 重跑调研报告后升 validated)
last_reviewed: 2026-09-28
reviewed_revision: 434d542c3cbdbeac38b9c0e547edf75160acf577
source_issues: [ISSUE-20260928-quality-heading-drift]
keywords: [quality_gate, 缺少必填章节, State-of-the-Art, H3, AutoRepair]
---
# 章节门禁比对标题,不比对空格和层级
- 触发条件:交付正文章节都在,质量报告仍写「缺少必填章节」,评分往往停在 85。
- 机制与来源:模板 H2 与模型常写的 H3、多一个或少一个空格、全角字母,在精确字符串比较下会被当成整章缺失。缺章会短路受控投影。见 ISSUE-20260928-quality-heading-drift。
- 开发检查:标题比对先去空白、折全角、忽略大小写,并允许 H2–H4。只在归一化后确实没有该标题时判缺失。层级不一致记软警告,不扣分。不要把「待确认」或材料不足算进这道门禁。
- 验证方式:`TestQualityGate_Validate_HeadingLevelAndSpaceDrift` 同时覆盖漂移通过和真实缺章失败。聊天触发须带 `AutoRepair`。
- 适用边界:只覆盖 ATX 标题存在性。近义改名、章节被写进代码块、内容空洞仍不在本条范围内。
- 防护落实:`internal/squad/quality_gate.go` 与 `quality_gate_test.go`。
- 修订与失效:若产品要求章节层级必须与模板一致才可交付,应把软警告改回硬失败,并同步改回归用例。
+1
View File
@@ -4,6 +4,7 @@
| lesson_id | 触发条件 / 关键词 | 注意事项 | lesson_state | 主记录 / 最近复核 |
|---|---|---|---|---|
| LESSON-20260928-quality-heading-normalize | 缺少必填章节、State-of-the-Art、H3、质量门禁、AutoRepair | 标题比对去空白并跨 H2–H4;层级漂移不扣分;真实缺章仍失败 | candidate | [章节标题归一化](2026-09-28-quality-heading-normalize.md);2026-09-28 |
| LESSON-20260928-task-write-verb-visible-refusal | 帮我定、制定、ClarificationQuestion、前置条件、quality_failed | 写动词覆盖制定与帮我定;拒绝和澄清进对话;质量失败不写宿主机路径 | candidate | [写动词与可见拒绝](2026-09-28-task-write-verb-visible-refusal.md);2026-09-28 |
| LESSON-20260928-activation-preview-client-gate | activation-preview、NO_GOVERNED_DOCUMENT、clientDutyIssues、400 | 服务端激活门禁要有同等前端预检;live_edit 不套激活门禁 | validated | [激活预检对齐](2026-09-28-activation-preview-client-gate.md);2026-09-28 |
| LESSON-20260928-squad-commit-as-author | 小队落盘、uow permission denied、AutoCommitUserID、OnBehalfOf、非编制人 | 自动提交身份用文档编制人,触发者只写入 OnBehalfOf;不要放宽 ActionCommit | candidate | [编制人代理提交](2026-09-28-squad-commit-as-author.md);2026-09-28 |
@@ -0,0 +1,69 @@
---
title: "质量门禁章节标题漂移不再误判缺章"
task_id: quality-gate-heading-mismatch-2026-09-28-r2
status: in_progress
created: 2026-09-28
updated: 2026-09-28
owner: Cursor
task_type: fix
policy_revision: "AGENTS.md + agent-worktree-workflow @ 2026-09-21"
branch: fix/quality-gate-heading-mismatch-2026-09-28
worktree: .worktrees/quality-gate-heading-mismatch-2026-09-28
base_dev_sha: "434d542c3cbdbeac38b9c0e547edf75160acf577"
write_scope:
- internal/squad
- internal/coordinator
- .doc
mutex: quality-gate-heading
review_state: pending
integration_state: local_only
deployment: not_deployed
cleanup: pending
remote_sync: not_requested
---
# 质量门禁章节标题漂移不再误判缺章
## 用户目标与授权
dev「吞咽电刺激」调研报告已写完,助手称已生成,界面却是「质量未通过」,受控文档没有更新。用户要求先确认失败原因;若只是项目信息仍在迭代,不得因此判质量不通过,并给出可落地修复。用户确认:标题做归一化并跨 H2–H4 匹配;聊天触发任务默认打开质量自动修复;门禁修复合入后重新发起调研报告。
## 检索
- 检索词:`quality_report`、`缺少必填章节`、`extractHeadings`、`质量未通过`、`AutoRepair`。
- 命中 [ISSUE-20260928-quality-failed-success-copy](../../audit/ISSUES.md):同步回复在质量失败时不得写宿主机路径、不得声称已完成。当前 `formatTriggerTaskReply` 已把非完成态交给 `CompletionSummary`,本任务不改这句文案,只补聊天链路的 `AutoRepair`。
- catalog overlap 语义层无命中。注册时若绑定 intent,会与仍为 active 的 `agent-reply-llm-narrative-2026-09-28`(scope `internal`+`.doc`)以及若干 scope `.` 的 integrate 任务发生 same_scope。该叙述任务改过 `engine_task.go` 的返回包装,与本次 `TaskRunRequest` 字段不在同一行。本次因此不绑 intent/mutex 到目录库,mutex 只写在 CURRENT 认领行。首个 task id 因 scope 不含 `.doc/ops` 无法认领,已 cancelled,改用 `quality-gate-heading-mismatch-2026-09-28-r2`。
## 根因
`research_1790566833205199368/manifest.json` 的 `quality_report.passed=false`,唯一违规是缺少「技术路线与 State-of-the-Art」。终稿该节在 `output.md` 第 279 行,标题是 `### 技术路线与State-of-the-Art`:层级降成 H3,且「与」后少了空格。门禁原先只抽 H2,清洗序号后做字符串精确相等,因此把格式漂移当成整章缺失。评分 85 仍高于 80,但缺章直接 `Passed=false`。
质量失败后 `finalizeSquadDeliverable` 按设计不投影受控文档,所以正文留在隔离工作区、项目文档不更新。这次 `workspace.json` 的 `project_id` 为 0,docID 为空,聊天触发又没设 `AutoRepair`,修复轮为 0。
门禁不检查「待确认」数量。信息还在迭代补齐,不是这次失败的原因。
## 修复
- 模板 H2 与正文 H2–H4 按归一化标题比对:去掉全部空白(含全角空格)、全角 ASCII 折半角、英文字母小写。只在 H3/H4 命中时记「章节层级与模板不一致」,不扣分、不阻断。归一化后仍没有的章节继续判缺失。
- `handleTriggerTask` 设置 `AutoRepair: true`,修复轮上限仍是 `DefaultMaxRepairRounds`。
- `module-boundaries.yaml` 为 coordinator 补上已有的 `projectworkflow` 依赖。该 import 在基线 `434d542c` 的 `engine_task.go` 里就存在,本 diff 没有新增 import;block 模式基线计数为 0,不补允许项则门禁对当前 dev 也是红的。
## 同类
dev 历史 `passed: false` 工作区里,`test_manager_1790215680339783789` 的「签署/说明」是「十一、」「十二、」序号,现有序号清洗已能对上。`intake_1789329815528283509` 当前正文已有「阻塞信息」「非阻塞信息」H2,不能用现状复现当时的缺章。`intake_1789269755280857331` 模板要「正文」,成稿没有这个标题,缺章判定仍然正确。
## 验收
单测覆盖:空格/全角/大小写漂移通过且有层级软警告;真实缺「正文」仍失败;聊天触发 `AutoRepair=true`;质量失败回复不含「已完成生成」。报告见 [质量门禁标题漂移测试报告](../../test-reports/2026-09-28-quality-gate-heading-mismatch-test-report.md)。合入 dev 后从项目上下文重跑调研报告,确认 `project_id>0` 且 `quality_report.passed=true`、受控文档有新 head。
## ISSUE-20260928-quality-heading-drift
- 预期:模板要求的章节只要正文里有同名内容,就不因空格或降为 H3/H4 被判整章缺失。
- 实际:调研报告终稿被判缺少「技术路线与 State-of-the-Art」,评分 85,文档不投影。
- 状态:verifying。单测已覆盖原例形状;dev 重跑调研报告后改为 resolved。
## ISSUE-20260928-data-dir-doubled
- 观察:存在 `data/dev/data/dev/workspaces/bizdev/intake_1790565513115586191/output.md`,该目录没有 `workspace.json`。正确工作区在 `data/dev/workspaces/bizdev/intake_1790565513115586191/`。
- 假设:某处把已含 `data/dev` 的 DataDir 又拼了一次。未沿调用链证实,本任务不修。
- 状态:open。复查触发:下次出现交付路径含 `data/dev/data/dev` 时沿写入点追查。
@@ -77,6 +77,7 @@ imports:
- kb
- maturity
- projectinstance
- projectworkflow
- squad
- stream
- workflowscope
@@ -0,0 +1,77 @@
# 质量门禁章节标题漂移 测试报告
## 一、 测试背景 (Background)
dev 项目「吞咽电刺激」调研报告 `research_1790566833205199368` 已生成正文,但 `manifest.json` 的 `quality_report.passed=false`,唯一违规是缺少「技术路线与 State-of-the-Art」。终稿该节实际在 H3,且标题少一个空格。质量失败短路了受控文档投影。
## 二、 测试目标 (Test Objectives)
- 空格、全角、大小写和 H3/H4 漂移不再被判成缺章,只记层级软警告。
- 正文确实没有的模板 H2 仍然失败。
- 聊天触发任务带上 `AutoRepair`。
- 质量失败的同步回复不声称「已完成生成」。
- `arch-check --mode block`、`go vet ./...`、`go test -short ./...` 通过。
## 三、 测试方案与环境 (Test Plan & Environment)
- 环境:Linux,worktree `.worktrees/quality-gate-heading-mismatch-2026-09-28`,基线 `434d542c`,修复提交 `aea19f70`、边界补记 `87888213`。
- 层级:工程工具 / Go 单测。产品 PI 全链路重跑调研报告不在本报告的工程门禁内,合入并重载 dev 后另验。
- 未改前端,不跑 `npm test`。
## 四、 预期结果 (Expected Results)
- `TestQualityGate_Validate_HeadingLevelAndSpaceDrift`:漂移稿通过且评分 100,并含层级软警告;缺「正文」失败。
- `TestSyncTriggerTask_EnablesAutoRepair`:`AutoRepair=true`。
- `TestFormatTriggerTaskReply_QualityFailedOmitsHostPath`:回复含「质量检查未通过」和违规摘要,不含「已完成生成」。
- 短测与 arch-check 退出码 0。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 用例 1:标题漂移与真实缺章
- 输入:`go test ./internal/squad/ ./internal/coordinator/ ./internal/bizdev/ -count=1 -timeout 240s`
- 实际输出:
```
ok chat-agent-system/internal/squad 0.041s
ok chat-agent-system/internal/coordinator 0.015s
ok chat-agent-system/internal/bizdev 0.227s
```
- 判定:PASS
- 耗时:约 2.5s(含同次 `go vet` 三个包,vet 退出码 0)
### 用例 2:arch-check
- 输入:`./scripts/arch-check.sh --mode block`
- 第一次实际输出(补允许项之前):`NEW import coordinator internal/coordinator/engine_task.go projectworkflow`,退出码 1。该 import 在基线 `434d542c` 已存在,不是本次功能 diff 引入。
- 补记 coordinator 允许依赖 `projectworkflow` 后:
```
ok chat-agent-system/internal/archtest 0.203s
ok chat-agent-system/internal/archtest 0.216s
arch-check block: production gate passed
```
- 判定:PASS
- 耗时:约 0.7s
### 用例 3:vet 与 short 全量
- 输入:`go vet ./... && go test -short ./... -count=1 -timeout 20m`
- 实际输出:退出码 0。抽查 `internal/squad` `0.038s`、`internal/server` `20.426s`,无 FAIL 行。
- 判定:PASS
- 耗时:25.2s
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 检查 | 结果 |
|---|---|
| `go test ./internal/squad/ ./internal/coordinator/ ./internal/bizdev/` | PASS |
| `go vet ./...` | PASS |
| `./scripts/arch-check.sh --mode block` | PASS(允许项补记后) |
| `go test -short ./...` | PASS |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
工程门禁达到本修复的单测交付点。合入 local dev 并重载 dev 制品后,须从项目上下文重跑「吞咽电刺激」调研报告,确认 `project_id>0`、`quality_report.passed=true`,且受控文档 head 更新。双层路径 `data/dev/data/dev` 未修,见 ISSUE-20260928-data-dir-doubled。
+3
View File
@@ -99,6 +99,9 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
Fields: userCtx.BlackboardFacts,
ProjectID: projectID,
UserID: userID,
// 会话触发与特设任务一致:质量门禁失败时给修复预算。
// 受控文档仍由 bizdev 强制开启;预算硬停在 DefaultMaxRepairRounds。
AutoRepair: true,
})
if err != nil {
if errors.Is(err, vocabulary.ErrDocumentBusy) {
@@ -17,13 +17,16 @@ import (
type pendingReplyBizdev struct {
res *stageflow.TaskRunResult
req stageflow.TaskRunRequest
}
func (p *pendingReplyBizdev) ExecuteTask(_ context.Context, req stageflow.TaskRunRequest) (*stageflow.TaskRunResult, error) {
p.req = req
return p.res, nil
}
func (p *pendingReplyBizdev) ListTasks() []stageflow.TaskMeta { return nil }
func (p *pendingReplyBizdev) ListStages() []stageflow.StageMeta { return nil }
func (p *pendingReplyBizdev) ExecuteTask(_ context.Context, _ stageflow.TaskRunRequest) (*stageflow.TaskRunResult, error) {
return p.res, nil
}
type errReplyBizdev struct {
res *stageflow.TaskRunResult
@@ -136,6 +139,38 @@ func TestSyncTriggerTask_Committed_KeepsSavedWording(t *testing.T) {
}
}
func TestSyncTriggerTask_EnablesAutoRepair(t *testing.T) {
stub := &pendingReplyBizdev{res: &stageflow.TaskRunResult{
Success: true,
TaskKey: "research",
ArtifactTitle: "调研报告",
WorkDir: t.TempDir(),
Duration: time.Millisecond,
}}
eng := NewCoordinatorEngine(stub)
userCtx := &UserProjectContext{
User: UserInfo{ID: "1", ProjectRole: "author"},
Project: ProjectInfo{ID: "9", Name: "吞咽电刺激", CurrentStage: maturity.StageV01_Triage},
}
cand := &intent.CandidateIntent{
Intent: intent.IntentTriggerTask,
FilledSlots: map[string]string{intent.SlotTargetTask: "research"},
}
if _, err := eng.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{
Branch: intent.BranchNewIntentPreempt,
TopIntent: cand,
RawInput: "编写调研报告",
}, userCtx); err != nil {
t.Fatal(err)
}
if !stub.req.AutoRepair {
t.Fatal("chat trigger must enable quality auto-repair")
}
if stub.req.TaskKey != "research" {
t.Fatalf("task key=%q", stub.req.TaskKey)
}
}
func TestFormatTriggerTaskReply_PendingUsesCompletionSummary(t *testing.T) {
pending := &stageflow.TaskRunResult{
Success: true,
+72 -19
View File
@@ -54,23 +54,42 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q
CheckedAt: now,
}
// 1. 检查标题完整性 (对比 template.md 的 ## 章节;清洗后须精确相等,禁止自拟近义标题)
// 1. 检查标题完整性。模板 H2 与正文 H2–H4 按归一化标题比对:
// 空白(含全角空格)去掉、全角 ASCII 折半角、英文字母大小写折叠。
// 同名但只出现在 H3/H4 时记层级软警告,不扣分、不阻断。
// 归一化后仍无命中才算缺章。禁止用近义标题顶替。
tmplPath := filepath.Join(wsDir, "template.md")
tmplData, err := os.ReadFile(tmplPath)
if err == nil {
expectedHeadings := extractHeadings(string(tmplData))
actualHeadings := extractHeadings(content)
actualSet := make(map[string]struct{}, len(actualHeadings))
expectedHeadings := parseMarkdownHeadings(string(tmplData), 2, 2)
actualHeadings := parseMarkdownHeadings(content, 2, 4)
actualByNorm := make(map[string][]mdHeading, len(actualHeadings))
for _, act := range actualHeadings {
actualSet[act] = struct{}{}
actualByNorm[act.Norm] = append(actualByNorm[act.Norm], act)
}
seenNorm := make(map[string]struct{}, len(expectedHeadings))
for _, h := range expectedHeadings {
if _, ok := actualSet[h]; ok {
if _, dup := seenNorm[h.Norm]; dup {
continue
}
report.MissingSections = append(report.MissingSections, h)
report.Violations = append(report.Violations, fmt.Sprintf("缺少必填章节: %s", h))
report.Score -= 15.0
seenNorm[h.Norm] = struct{}{}
matches := actualByNorm[h.Norm]
if len(matches) == 0 {
report.MissingSections = append(report.MissingSections, h.Title)
report.Violations = append(report.Violations, fmt.Sprintf("缺少必填章节: %s", h.Title))
report.Score -= 15.0
continue
}
hasH2 := false
for _, m := range matches {
if m.Level == 2 {
hasH2 = true
break
}
}
if !hasH2 {
report.Violations = append(report.Violations, fmt.Sprintf("章节层级与模板不一致: %s", h.Title))
}
}
if len(report.MissingSections) > 0 {
report.TitleCheck = false
@@ -686,22 +705,56 @@ func (q *QualityGate) EnsureQualityOrRepair(
return report, nil
}
func extractHeadings(md string) []string {
var headings []string
lines := strings.Split(md, "\n")
for _, line := range lines {
type mdHeading struct {
Level int
Title string
Norm string
}
// parseMarkdownHeadings 提取闭区间 [minLevel, maxLevel] 的 ATX 标题。
// 标题文本先去序号,再做门禁比对用的归一化。
func parseMarkdownHeadings(md string, minLevel, maxLevel int) []mdHeading {
var headings []mdHeading
for _, line := range strings.Split(md, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "## ") {
raw := strings.TrimPrefix(trimmed, "## ")
clean := cleanHeadingTitle(raw)
if clean != "" {
headings = append(headings, clean)
}
level := 0
for level < len(trimmed) && trimmed[level] == '#' {
level++
}
if level < minLevel || level > maxLevel || level >= len(trimmed) || trimmed[level] != ' ' {
continue
}
clean := cleanHeadingTitle(strings.TrimSpace(trimmed[level+1:]))
if clean == "" {
continue
}
headings = append(headings, mdHeading{
Level: level,
Title: clean,
Norm: normalizeHeadingTitle(clean),
})
}
return headings
}
// normalizeHeadingTitle 生成章节比对键:去掉全部空白、全角 ASCII 折半角、英文字母小写。
// 缺空格(「与 State」对「与State」)与大小写漂移不再被当成整章缺失。
func normalizeHeadingTitle(title string) string {
t := cleanHeadingTitle(title)
var b strings.Builder
b.Grow(len(t))
for _, r := range t {
if unicode.IsSpace(r) {
continue
}
if r >= 0xFF01 && r <= 0xFF5E {
r -= 0xFEE0
}
b.WriteRune(r)
}
return strings.ToLower(b.String())
}
func isChineseNumeralRune(r rune) bool {
switch r {
case '零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '百', '千', '两':
@@ -2,3 +2,6 @@ package squad
// CleanHeadingTitleForTest 暴露 cleanHeadingTitle,供同模块白盒用例验证序号清洗。
var CleanHeadingTitleForTest = cleanHeadingTitle
// NormalizeHeadingTitleForTest 暴露 normalizeHeadingTitle,供标题归一化回归。
var NormalizeHeadingTitleForTest = normalizeHeadingTitle
+78
View File
@@ -532,3 +532,81 @@ func TestQualityGate_Validate_ChineseOrdinalElevenTwelve(t *testing.T) {
t.Fatalf("expected eleven/twelve ordinal headings to match template, violations=%v", rep.Violations)
}
}
func TestNormalizeHeadingTitle_FoldsSpaceWidthAndCase(t *testing.T) {
cases := []struct {
in, want string
}{
{"技术路线与 State-of-the-Art", "技术路线与state-of-the-art"},
{"技术路线与State-of-the-Art", "技术路线与state-of-the-art"},
{"技术路线与 State-of-the-Art", "技术路线与state-of-the-art"},
{"十一、签署", "签署"},
}
for _, c := range cases {
got := squad.NormalizeHeadingTitleForTest(c.in)
if got != c.want {
t.Errorf("normalizeHeadingTitle(%q)=%q want %q", c.in, got, c.want)
}
}
}
func TestQualityGate_Validate_HeadingLevelAndSpaceDrift(t *testing.T) {
tmpDir := t.TempDir()
engine := squad.NewWorkspaceEngine()
ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{
WorkDir: filepath.Join(tmpDir, "ws_research"),
TaskKey: "research",
StageTitle: "调研报告",
ProjectName: "吞咽电刺激",
TemplateContent: "# 调研报告\n\n" +
"## 结论\n\n## 技术路线与 State-of-the-Art\n\n## 阻塞信息\n\n## 正文\n",
})
if err != nil {
t.Fatalf("PrepareWorkspace: %v", err)
}
gate := squad.NewQualityGate()
drifted := "# 调研报告\n\n" +
"## 结论\n结论句。\n\n" +
"### 技术路线与State-of-the-Art\n路线对比。\n\n" +
"### 阻塞信息\n待确认。\n\n" +
"## 2. 正文\n正文展开。\n"
rep, err := gate.Validate(ws.WorkDir, "research", drifted)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if !rep.Passed {
t.Fatalf("space/level drift must not fail the gate, violations=%v missing=%v", rep.Violations, rep.MissingSections)
}
if rep.Score != 100 {
t.Fatalf("level warning must not deduct score, got %v", rep.Score)
}
joined := strings.Join(rep.Violations, "\n")
if !strings.Contains(joined, "章节层级与模板不一致: 技术路线与 State-of-the-Art") {
t.Fatalf("missing level warning: %v", rep.Violations)
}
if !strings.Contains(joined, "章节层级与模板不一致: 阻塞信息") {
t.Fatalf("missing blocking-info level warning: %v", rep.Violations)
}
if len(rep.MissingSections) != 0 {
t.Fatalf("missing=%v", rep.MissingSections)
}
absent := "# 调研报告\n\n## 结论\n有结论。\n\n## 技术路线与 State-of-the-Art\n有路线。\n\n## 阻塞信息\n有阻塞。\n"
miss, err := gate.Validate(ws.WorkDir, "research", absent)
if err != nil {
t.Fatalf("Validate absent: %v", err)
}
if miss.Passed {
t.Fatal("truly absent 正文 must still fail")
}
found := false
for _, s := range miss.MissingSections {
if s == "正文" {
found = true
}
}
if !found {
t.Fatalf("missing sections=%v", miss.MissingSections)
}
}