@tea-agent/loop-agent 0.25.5 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +1020 -986
  3. package/bin/loop-agent.js +21 -21
  4. package/dist/commands/cursor-prompt.js +6 -6
  5. package/dist/commands/init.js +68 -129
  6. package/dist/commands/loop-benchmark.js +11 -11
  7. package/dist/commands/pi-reuse-benchmark.js +16 -16
  8. package/dist/executors/dag-pi-executor.js +26 -20
  9. package/dist/executors/model-routing.js +34 -18
  10. package/dist/executors/shell-executor.js +1 -1
  11. package/dist/governance/manifest-types.js +33 -5
  12. package/dist/sidecars/cursor-prompt/executor.js +1 -1
  13. package/dist/task/task-demand-routing.js +3 -1
  14. package/dist/worker/console/chat/model-resolver.js +15 -3
  15. package/dist/worker/observe/static/constants.js +3 -2
  16. package/dist/worker/observe/static/copy.js +67 -67
  17. package/dist/worker/observe/static/dag-layout.d.ts +31 -31
  18. package/dist/worker/observe/static/dag-layout.js +83 -83
  19. package/dist/worker/observe/static/dag-model.js +1 -0
  20. package/dist/worker/observe/static/dom.js +220 -220
  21. package/dist/worker/observe/static/relations.js +133 -133
  22. package/dist/worker/observe/static/router.js +93 -93
  23. package/dist/worker/observe/static/run-processing.js +148 -148
  24. package/dist/worker/observe/static/styles.css +182 -42
  25. package/dist/worker/observe/static/views/batch.js +227 -227
  26. package/dist/worker/observe/static/views/dag-graph.js +172 -172
  27. package/dist/worker/observe/static/views/failures.js +143 -143
  28. package/dist/worker/observe/static/views/feature.js +492 -492
  29. package/dist/worker/observe/static/views/run.js +453 -453
  30. package/dist/worker/observe/static/views/shell.js +7 -7
  31. package/dist/worker/observe/static/views/timeline.js +163 -163
  32. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +362 -45
  33. package/dist/workflows/dag/backend-test-case-manifest.js +20 -0
  34. package/dist/workflows/dag/backend-test-markdown-workflow.js +17 -0
  35. package/dist/workflows/dag/canvas-observer.js +275 -275
  36. package/dist/workflows/dag/init-hybrid.js +18 -15
  37. package/dist/workflows/dag/lifecycle.js +40 -30
  38. package/dist/workflows/dag/node-execution.js +13 -0
  39. package/dist/workflows/dag/types.js +59 -19
  40. package/docs/init-surface.manifest.json +3 -1
  41. package/docs/skills/README.md +7 -7
  42. package/docs/templates/README.md +1 -0
  43. package/docs/templates/adr.md +60 -60
  44. package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
  45. package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
  46. package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
  47. package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
  48. package/docs/templates/agent-dag-report.schema.json +473 -473
  49. package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
  50. package/docs/templates/backend-test-dag.json +9 -9
  51. package/docs/templates/backend-test-result.schema.json +99 -99
  52. package/docs/templates/feature-spec.md +53 -53
  53. package/docs/templates/frontend-design-contract.md +42 -42
  54. package/docs/templates/frontend-eval/fixtures/failures/01-type-build-error.md +17 -17
  55. package/docs/templates/frontend-eval/fixtures/failures/02-unit-component-test-fail.md +16 -16
  56. package/docs/templates/frontend-eval/fixtures/failures/03-fixture-schema-drift.md +16 -16
  57. package/docs/templates/frontend-eval/fixtures/failures/04-missing-loading-empty-error-state.md +16 -16
  58. package/docs/templates/frontend-eval/fixtures/failures/05-forbidden-write-writeset-expansion.md +16 -16
  59. package/docs/templates/frontend-eval/fixtures/failures/06-unapproved-dependency-add.md +16 -16
  60. package/docs/templates/frontend-eval/fixtures/failures/07-mock-production-on.md +21 -21
  61. package/docs/templates/frontend-eval/fixtures/functional/01-simple-component-style.md +29 -29
  62. package/docs/templates/frontend-eval/fixtures/functional/02-form-validation.md +28 -28
  63. package/docs/templates/frontend-eval/fixtures/functional/03-list-detail-page.md +28 -28
  64. package/docs/templates/frontend-eval/fixtures/functional/04-api-mock.md +29 -29
  65. package/docs/templates/frontend-eval/fixtures/functional/05-permission-auth-gated-ui.md +27 -27
  66. package/docs/templates/frontend-eval/fixtures/functional/06-ssr-server-client-boundary.md +28 -28
  67. package/docs/templates/frontend-eval/fixtures/functional/07-shared-public-component-api.md +28 -28
  68. package/docs/templates/frontend-eval/fixtures/functional/08-pure-local-no-remote.md +27 -27
  69. package/docs/templates/frontend-eval/metrics.md +138 -138
  70. package/docs/templates/frontend-eval/smoke-targets.md +53 -53
  71. package/docs/templates/frontend-task-constraints.md +35 -35
  72. package/docs/templates/frontend-task-requirement.md +70 -70
  73. package/docs/templates/harness.schema.json +29 -7
  74. package/docs/templates/init-evolution-review.md +35 -35
  75. package/docs/templates/init-managed-agents.md +137 -0
  76. package/docs/templates/interactive-ui-round2-experiment.md +66 -66
  77. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -118
  78. package/docs/templates/knowledge-sync-dag.json +178 -178
  79. package/docs/templates/knowledge-sync-draft.schema.json +71 -71
  80. package/docs/templates/product-line/closeout.yaml +9 -9
  81. package/docs/templates/product-line/design.md +13 -13
  82. package/docs/templates/product-line/links.md +10 -10
  83. package/docs/templates/product-line/requirement.md +17 -17
  84. package/docs/templates/product-line/test-plan.md +7 -7
  85. package/docs/templates/production-readiness-checklist.md +57 -57
  86. package/docs/templates/project-start-checklist.md +9 -9
  87. package/docs/templates/qa-report.md +48 -48
  88. package/docs/templates/sprint-contract.md +29 -29
  89. package/docs/templates/worker-dogfood-evidence.md +80 -80
  90. package/docs/templates/worker-dogfood-setup.md +68 -68
  91. package/harness.json +1 -2
  92. package/package.json +1 -1
  93. package/scripts/kb-bootstrap-init-skeleton.sh +0 -0
  94. package/scripts/kb-graph-incremental-prepare.mjs +386 -386
  95. package/scripts/kb-graph-materialize.mjs +105 -105
  96. package/scripts/kb-graph-promote.mjs +164 -164
  97. package/scripts/kb-query.mjs +554 -554
  98. package/skills/ai-engineering-context/SKILL.md +48 -48
  99. package/skills/analyze-product-dependencies/SKILL.md +67 -67
  100. package/skills/analyze-product-dependencies/agents/openai.yaml +4 -4
  101. package/skills/analyze-product-dependencies/references/api-documentation-schema.md +30 -30
  102. package/skills/analyze-product-dependencies/references/dependency-analysis-schema.md +28 -28
  103. package/skills/analyze-product-dependencies/references/example.md +76 -76
  104. package/skills/analyze-product-dependencies/references/forward-test-cases.md +35 -35
  105. package/skills/analyze-product-dependencies/references/input-contract.md +11 -11
  106. package/skills/analyze-product-dependencies/references/scouting-rules.md +61 -61
  107. package/skills/analyze-product-dependencies/scripts/test-validators.mjs +267 -267
  108. package/skills/analyze-product-dependencies/scripts/validate-api-documentation.mjs +101 -101
  109. package/skills/analyze-product-dependencies/scripts/validate-dependency-analysis.mjs +142 -142
  110. package/skills/analyze-product-dependencies/scripts/validate-product-requirement-input.mjs +76 -76
  111. package/skills/analyze-product-dependencies/scripts/validation-helpers.mjs +146 -146
  112. package/skills/analyze-product-requirements/SKILL.md +90 -90
  113. package/skills/analyze-product-requirements/agents/openai.yaml +4 -4
  114. package/skills/analyze-product-requirements/references/acceptance-criteria.md +91 -91
  115. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +56 -56
  116. package/skills/analyze-product-requirements/references/example.md +86 -86
  117. package/skills/analyze-product-requirements/references/forward-test-cases.md +66 -66
  118. package/skills/analyze-product-requirements/references/product-analysis-schema.md +32 -32
  119. package/skills/analyze-product-requirements/references/product-requirement-schema.md +33 -33
  120. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +35 -35
  121. package/skills/analyze-product-requirements/scripts/test-validators.mjs +193 -193
  122. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +69 -69
  123. package/skills/analyze-product-requirements/scripts/validate-product-requirement.mjs +97 -97
  124. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +98 -98
  125. package/skills/analyze-product-requirements/scripts/validation-helpers.mjs +156 -156
  126. package/skills/browser-tools/browser-content.js +103 -103
  127. package/skills/browser-tools/browser-cookies.js +35 -35
  128. package/skills/browser-tools/browser-eval.js +53 -53
  129. package/skills/browser-tools/browser-hn-scraper.js +108 -108
  130. package/skills/browser-tools/browser-nav.js +44 -44
  131. package/skills/browser-tools/browser-pick.js +162 -162
  132. package/skills/browser-tools/browser-screenshot.js +34 -34
  133. package/skills/browser-tools/browser-start.js +86 -86
  134. package/skills/browser-tools/package-lock.json +2556 -2556
  135. package/skills/browser-tools/package.json +19 -19
  136. package/skills/code-review-core/SKILL.md +20 -20
  137. package/skills/codebase-scout/SKILL.md +19 -19
  138. package/skills/grill-me/SKILL.md +10 -10
  139. package/skills/loop-agent/references/README.md +67 -67
  140. package/skills/loop-agent/references/command-reference.md +5 -4
  141. package/skills/loop-agent/references/docs-converge.md +126 -126
  142. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  143. package/skills/loop-agent/references/learned/README.md +21 -21
  144. package/skills/loop-agent/references/long-running-loop.md +57 -57
  145. package/skills/loop-agent/references/model-routing.md +2 -0
  146. package/skills/loop-agent/references/one-shot-runs.md +85 -85
  147. package/skills/loop-agent/references/pi-prompt.md +23 -23
  148. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +84 -84
  149. package/skills/playwright-cli/SKILL.md +420 -420
  150. package/skills/playwright-cli/references/element-attributes.md +23 -23
  151. package/skills/playwright-cli/references/playwright-tests.md +39 -39
  152. package/skills/playwright-cli/references/request-mocking.md +87 -87
  153. package/skills/playwright-cli/references/running-code.md +241 -241
  154. package/skills/playwright-cli/references/session-management.md +225 -225
  155. package/skills/playwright-cli/references/storage-state.md +275 -275
  156. package/skills/playwright-cli/references/test-generation.md +433 -433
  157. package/skills/playwright-cli/references/tracing.md +139 -139
  158. package/skills/playwright-cli/references/video-recording.md +143 -143
  159. package/skills/requesting-code-review/SKILL.md +101 -101
  160. package/skills/requesting-code-review/code-reviewer.md +168 -168
  161. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  162. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  163. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  164. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  165. package/skills/systematic-debugging/find-polluter.sh +63 -63
  166. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  167. package/skills/systematic-debugging/test-academic.md +14 -14
  168. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  169. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  170. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  171. package/skills/using-git-worktrees/SKILL.md +215 -215
  172. package/skills/verification-before-completion/SKILL.md +154 -154
  173. package/skills/webapp-testing/SKILL.md +19 -19
@@ -4,10 +4,11 @@ import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import YAML from "yaml";
6
6
  import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
7
- const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{2,3})\b.*$/gm;
8
- const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{2,3}\b/g;
7
+ const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
8
+ const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
9
+ const NON_CANONICAL_CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-(?:\d{2}|\d{2,3}[A-Z]+))\b.*$/gm;
9
10
  const AC_ID_IN_TEXT = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
10
- const RULE_KEY = /^(?:AC|REQ|BR|API)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
11
+ const RULE_KEY = /^(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
11
12
  const TEST_POINT = /^TP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
12
13
  const COVERAGE_HEADERS = [
13
14
  "Rule Key",
@@ -27,6 +28,40 @@ const CASE_SECTION_ALIASES = {
27
28
  scenarioTypes: ["场景类型", "Scenario Types", "Scenario Type"],
28
29
  automation: ["自动化映射", "自动化说明", "Automation Notes"],
29
30
  };
31
+ const TEST_POINT_BINDING_MODES = ["variant", "assertion", "cross-cutting"];
32
+ const CHANGE_CLASSIFICATIONS = [
33
+ "new-operation",
34
+ "contract-change",
35
+ "behavior-change",
36
+ "bugfix",
37
+ "implementation-optimization",
38
+ ];
39
+ const COVERAGE_POLICIES = [
40
+ "full-contract",
41
+ "affected-contract-full",
42
+ "affected-behavior-full",
43
+ "reproduction-plus-neighbors",
44
+ "change-focused-plus-regression-floor",
45
+ ];
46
+ const COVERAGE_POLICY_BY_CLASSIFICATION = {
47
+ "new-operation": "full-contract",
48
+ "contract-change": "affected-contract-full",
49
+ "behavior-change": "affected-behavior-full",
50
+ bugfix: "reproduction-plus-neighbors",
51
+ "implementation-optimization": "change-focused-plus-regression-floor",
52
+ };
53
+ const REQUIRED_REGRESSION_FLOOR = {
54
+ "new-operation": [],
55
+ "contract-change": ["main-success-path", "unchanged-response-shape", "changed-contract-boundaries"],
56
+ "behavior-change": ["main-success-path", "unchanged-response-shape", "affected-state-transition"],
57
+ bugfix: ["main-success-path", "unchanged-response-shape", "defect-reproduction", "adjacent-boundary"],
58
+ "implementation-optimization": ["main-success-path", "unchanged-response-shape"],
59
+ };
60
+ const AUTOMATION_BINDING_LABELS = {
61
+ variant: ["变体测试点", "Variant Test Points"],
62
+ assertion: ["场景断言测试点", "Assertion Test Points"],
63
+ "cross-cutting": ["横切证据测试点", "Cross-Cutting Test Points"],
64
+ };
30
65
  const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
31
66
  const inputFileSchema = z.object({ path: z.string().min(1), sha256: sha256Schema }).strict();
32
67
  const sourceBindingSchema = z.object({
@@ -43,8 +78,15 @@ const coverageCaseSchema = z.object({
43
78
  acIds: z.array(z.string()),
44
79
  ruleKeys: z.array(z.string()),
45
80
  testPoints: z.array(z.string()),
81
+ testPointBindings: z.array(z.object({
82
+ testPoint: z.string(),
83
+ mode: z.enum(TEST_POINT_BINDING_MODES),
84
+ }).strict()),
85
+ unclassifiedTestPoints: z.array(z.string()),
86
+ duplicateBindingTestPoints: z.array(z.string()),
46
87
  scenarioTypes: z.array(z.string()),
47
88
  declaredScripts: z.array(z.string()),
89
+ declaredPrimarySymbols: z.array(z.string()),
48
90
  }).strict();
49
91
  const coverageRuleSchema = z.object({
50
92
  ruleKey: z.string().min(1),
@@ -60,13 +102,23 @@ const coverageRuleSchema = z.object({
60
102
  missingTestPoints: z.array(z.string()),
61
103
  status: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
62
104
  }).strict();
105
+ const coverageScopeSchema = z.object({
106
+ changeClassification: z.enum(CHANGE_CLASSIFICATIONS),
107
+ coveragePolicy: z.enum(COVERAGE_POLICIES),
108
+ affectedOperations: z.array(z.string().min(1)),
109
+ affectedRuleKeys: z.array(z.string().min(1)),
110
+ regressionFloor: z.array(z.string().min(1)),
111
+ scopeEvidence: z.array(z.string().min(1)),
112
+ completenessClaim: z.enum(["affected-operations-full", "affected-scope"]),
113
+ }).strict();
63
114
  export const backendTestCaseCoverageFactsSchema = z.object({
64
- schemaId: z.literal("backend-test-case-coverage-facts-v1"),
65
- schemaVersion: z.literal(1),
115
+ schemaId: z.literal("backend-test-case-coverage-facts-v3"),
116
+ schemaVersion: z.literal(3),
66
117
  taskId: z.string().min(1),
67
118
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
68
119
  sourceBinding: sourceBindingSchema,
69
120
  inputFiles: z.array(inputFileSchema),
121
+ coverageScope: coverageScopeSchema,
70
122
  summary: z.object({
71
123
  explicitAcCount: z.number().int().min(0),
72
124
  coveredAcCount: z.number().int().min(0),
@@ -86,6 +138,11 @@ export const backendTestCaseCoverageFactsSchema = z.object({
86
138
  coveredBusinessStateCount: z.number().int().min(0),
87
139
  gapCount: z.number().int().min(0),
88
140
  conflictCount: z.number().int().min(0),
141
+ variantTestPointCount: z.number().int().min(0),
142
+ assertionTestPointCount: z.number().int().min(0),
143
+ crossCuttingTestPointCount: z.number().int().min(0),
144
+ unclassifiedTestPointCount: z.number().int().min(0),
145
+ duplicateBindingTestPointCount: z.number().int().min(0),
89
146
  }).strict(),
90
147
  cases: z.array(coverageCaseSchema),
91
148
  rules: z.array(coverageRuleSchema),
@@ -97,7 +154,12 @@ const correspondenceEntrySchema = z.object({
97
154
  markdownModule: z.string().min(1),
98
155
  caseId: z.string().optional(),
99
156
  testPoints: z.array(z.string()),
157
+ variantTestPoints: z.array(z.string()),
158
+ assertionTestPoints: z.array(z.string()),
159
+ crossCuttingTestPoints: z.array(z.string()),
160
+ mappedTestPoints: z.array(z.string()),
100
161
  declaredScript: z.string().optional(),
162
+ declaredPrimarySymbol: z.string().optional(),
101
163
  expectedScript: z.string().min(1),
102
164
  actualScripts: z.array(z.string()),
103
165
  pytestSymbols: z.array(z.string()),
@@ -110,14 +172,17 @@ const correspondenceEntrySchema = z.object({
110
172
  "EXTRA_PYTEST",
111
173
  "SCRIPT_MISMATCH",
112
174
  "SYMBOL_MISSING_CASE_ID",
113
- "PARAMETER_POINT_MISSING",
114
- "PARAMETER_POINT_EXTRA",
175
+ "VARIANT_PARAMETER_MISSING",
176
+ "ASSERTION_BINDING_MISSING",
177
+ "CROSS_CUTTING_EVIDENCE_MISSING",
178
+ "TEST_POINT_BINDING_DUPLICATE",
179
+ "TEST_POINT_BINDING_EXTRA",
115
180
  ]),
116
181
  findings: z.array(z.string()),
117
182
  }).strict();
118
183
  export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
119
- schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v1"),
120
- schemaVersion: z.literal(1),
184
+ schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v2"),
185
+ schemaVersion: z.literal(2),
121
186
  taskId: z.string().min(1),
122
187
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
123
188
  inputFiles: z.array(inputFileSchema),
@@ -126,12 +191,18 @@ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
126
191
  exactModuleCount: z.number().int().min(0),
127
192
  markdownCaseCount: z.number().int().min(0),
128
193
  exactCorrespondenceCount: z.number().int().min(0),
194
+ primarySymbolCount: z.number().int().min(0),
129
195
  missingPytestCount: z.number().int().min(0),
130
196
  multiplePytestCount: z.number().int().min(0),
131
197
  extraPytestCount: z.number().int().min(0),
132
198
  scriptMismatchCount: z.number().int().min(0),
133
199
  testPointCount: z.number().int().min(0),
134
200
  mappedTestPointCount: z.number().int().min(0),
201
+ variantTestPointCount: z.number().int().min(0),
202
+ assertionTestPointCount: z.number().int().min(0),
203
+ crossCuttingTestPointCount: z.number().int().min(0),
204
+ unclassifiedTestPointCount: z.number().int().min(0),
205
+ duplicateBindingTestPointCount: z.number().int().min(0),
135
206
  }).strict(),
136
207
  entries: z.array(correspondenceEntrySchema),
137
208
  findings: z.array(z.string()),
@@ -165,10 +236,19 @@ function openApiTestPoints(field, dimension, values = []) {
165
236
  return [`${prefix}-VALID-CLASS`, `${prefix}-VALID-MIXED`, `${prefix}-UPPERCASE`, `${prefix}-WHITESPACE`, `${prefix}-PUNCTUATION`, `${prefix}-SLASH`, `${prefix}-EMOJI`, `${prefix}-CONTROL`];
166
237
  return [`${prefix}-PRESENT`, `${prefix}-MISSING`, `${prefix}-NULL`, `${prefix}-WRONG-TYPE`];
167
238
  }
239
+ function resolveBoundSourcePath(workspaceRoot, taskId, sourcePath) {
240
+ if (path.isAbsolute(sourcePath))
241
+ return sourcePath;
242
+ const normalized = sourcePath.replaceAll("\\", "/").replace(/^\.\//, "");
243
+ const boundPrefix = `.harness/tasks/${taskId}/`;
244
+ return normalized.startsWith(boundPrefix)
245
+ ? path.join(workspaceRoot, ...normalized.split("/"))
246
+ : path.join(workspaceRoot, ".harness", "tasks", taskId, ...normalized.split("/"));
247
+ }
168
248
  export async function extractBackendTestOpenApiRules(input) {
169
249
  const rules = [];
170
250
  for (const referencePath of input.sourceBinding.referencePaths) {
171
- const physicalPath = path.join(input.workspaceRoot, ".harness", "tasks", input.sourceBinding.taskId, referencePath);
251
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
172
252
  let document;
173
253
  try {
174
254
  const parsed = YAML.parse(await readFile(physicalPath, "utf8"));
@@ -287,11 +367,8 @@ function orderedUnique(values) {
287
367
  return result;
288
368
  }
289
369
  function canonicalCaseId(value) {
290
- const upper = value.toUpperCase();
291
- const match = upper.match(/^(BE-[A-Z0-9_-]+)-(\d{2,3})$/);
292
- if (!match)
293
- return upper;
294
- return `${match[1].replaceAll("_", "-").replace(/-+/g, "-")}-${match[2].padStart(3, "0")}`;
370
+ const upper = value.toUpperCase().replaceAll("_", "-").replace(/-+/g, "-");
371
+ return /^(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*)-\d{3}$/.test(upper) ? upper : value.toUpperCase();
295
372
  }
296
373
  function caseIds(value) {
297
374
  return orderedUnique((value.match(CASE_ID_IN_TEXT) ?? []).map(canonicalCaseId));
@@ -323,6 +400,31 @@ function declaredScripts(body) {
323
400
  return orderedUnique([...automation.matchAll(/testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py/gi)]
324
401
  .map((match) => match[0].replaceAll("\\", "/")));
325
402
  }
403
+ function automationLabelBody(body, labels) {
404
+ const automation = sectionBody(body, CASE_SECTION_ALIASES.automation);
405
+ const escaped = labels.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
406
+ const match = new RegExp(`^\\s*[-*+]\\s*(?:${escaped})\\s*[::]\\s*(.*)$`, "mi").exec(automation);
407
+ return match?.[1]?.replaceAll("`", "").trim() ?? "";
408
+ }
409
+ function testPointBindingFacts(body, testPoints) {
410
+ const bindings = TEST_POINT_BINDING_MODES.flatMap((mode) => {
411
+ const value = automationLabelBody(body, AUTOMATION_BINDING_LABELS[mode]);
412
+ return orderedUnique(value.match(/\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? [])
413
+ .map((testPoint) => ({ testPoint, mode }));
414
+ });
415
+ const counts = new Map();
416
+ for (const binding of bindings)
417
+ counts.set(binding.testPoint, (counts.get(binding.testPoint) ?? 0) + 1);
418
+ return {
419
+ testPointBindings: bindings,
420
+ unclassifiedTestPoints: testPoints.filter((testPoint) => !counts.has(testPoint)),
421
+ duplicateBindingTestPoints: testPoints.filter((testPoint) => (counts.get(testPoint) ?? 0) > 1),
422
+ };
423
+ }
424
+ function declaredPrimarySymbols(body) {
425
+ const value = automationLabelBody(body, ["primary symbol", "Primary Symbol", "主测试符号"]);
426
+ return orderedUnique(value.match(/\btest_[A-Za-z0-9_]+\b/g) ?? []);
427
+ }
326
428
  function cleanTitle(heading, id) {
327
429
  return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
328
430
  }
@@ -352,15 +454,18 @@ async function parseMarkdownCases(workspaceRoot) {
352
454
  const heading = headings[index];
353
455
  const id = canonicalCaseId(heading[1]);
354
456
  const body = markdown.slice(heading.index, headings[index + 1]?.index ?? markdown.length);
457
+ const testPoints = listTokens(body, CASE_SECTION_ALIASES.testPoints, TEST_POINT);
355
458
  result.push({
356
459
  caseId: id,
357
460
  title: cleanTitle(heading[0], id),
358
461
  markdownPath: path.relative(workspaceRoot, file).replaceAll(path.sep, "/"),
359
462
  acIds: orderedUnique(body.match(AC_ID_IN_TEXT) ?? []),
360
463
  ruleKeys: listTokens(body, CASE_SECTION_ALIASES.rules, RULE_KEY),
361
- testPoints: listTokens(body, CASE_SECTION_ALIASES.testPoints, TEST_POINT),
464
+ testPoints,
465
+ ...testPointBindingFacts(body, testPoints),
362
466
  scenarioTypes: scenarioTypes(body),
363
467
  declaredScripts: declaredScripts(body),
468
+ declaredPrimarySymbols: declaredPrimarySymbols(body),
364
469
  body,
365
470
  });
366
471
  }
@@ -392,6 +497,89 @@ function splitCellTokens(value, pattern) {
392
497
  const values = orderedUnique(value.split(/;|<br\s*\/?\s*>|,|、/i).map((item) => item.replaceAll("`", "").trim()).filter(Boolean));
393
498
  return pattern ? values.filter((item) => pattern.test(item)) : values;
394
499
  }
500
+ function parseCoverageScope(readme) {
501
+ const findings = [];
502
+ const fallback = {
503
+ changeClassification: "new-operation",
504
+ coveragePolicy: "full-contract",
505
+ affectedOperations: [],
506
+ affectedRuleKeys: [],
507
+ regressionFloor: [],
508
+ scopeEvidence: [],
509
+ completenessClaim: "affected-operations-full",
510
+ };
511
+ const marker = /^##\s+Coverage Scope\s*$/mi.exec(readme);
512
+ if (!marker)
513
+ return { scope: fallback, findings: ["README is missing required ## Coverage Scope section; defaulted to full-contract"] };
514
+ const rest = readme.slice(marker.index + marker[0].length);
515
+ const nextHeading = /^##\s+/m.exec(rest);
516
+ const section = rest.slice(0, nextHeading?.index ?? rest.length);
517
+ const lines = section.split(/\r?\n/).filter((line) => line.trim().startsWith("|"));
518
+ if (lines.length < 2 || JSON.stringify(parseTableRow(lines[0])) !== JSON.stringify(["Field", "Value"])) {
519
+ return { scope: fallback, findings: ["Coverage Scope table headers must be exactly: Field | Value"] };
520
+ }
521
+ const separatorCells = lines[1] ? parseTableRow(lines[1]) : [];
522
+ const hasSeparator = separatorCells.length === 2 && separatorCells.every((cell) => /^:?-{3,}:?$/.test(cell));
523
+ if (!hasSeparator)
524
+ findings.push("Coverage Scope table is missing the required |---|---| separator row");
525
+ const values = new Map();
526
+ for (const line of lines.slice(hasSeparator ? 2 : 1)) {
527
+ const cells = parseTableRow(line);
528
+ if (cells.length !== 2) {
529
+ findings.push(`Coverage Scope row must have exactly 2 columns: ${line.slice(0, 160)}`);
530
+ continue;
531
+ }
532
+ const field = cells[0].trim();
533
+ if (values.has(field))
534
+ findings.push(`duplicate Coverage Scope field: ${field}`);
535
+ values.set(field, cells[1].trim());
536
+ }
537
+ const classificationRaw = (values.get("Change Classification") ?? "").replaceAll("`", "").trim();
538
+ const policyRaw = (values.get("Coverage Policy") ?? "").replaceAll("`", "").trim();
539
+ const changeClassification = CHANGE_CLASSIFICATIONS.includes(classificationRaw)
540
+ ? classificationRaw
541
+ : fallback.changeClassification;
542
+ const coveragePolicy = COVERAGE_POLICIES.includes(policyRaw)
543
+ ? policyRaw
544
+ : fallback.coveragePolicy;
545
+ if (!CHANGE_CLASSIFICATIONS.includes(classificationRaw))
546
+ findings.push(`invalid or missing Change Classification: ${classificationRaw || "<empty>"}`);
547
+ if (!COVERAGE_POLICIES.includes(policyRaw))
548
+ findings.push(`invalid or missing Coverage Policy: ${policyRaw || "<empty>"}`);
549
+ const expectedPolicy = COVERAGE_POLICY_BY_CLASSIFICATION[changeClassification];
550
+ if (coveragePolicy !== expectedPolicy)
551
+ findings.push(`Change Classification ${changeClassification} requires Coverage Policy ${expectedPolicy}, received ${coveragePolicy}`);
552
+ const affectedOperations = splitCellTokens(values.get("Affected Operations") ?? "").map((value) => value.replace(/\s+/g, " ").trim());
553
+ const affectedRuleKeys = splitCellTokens(values.get("Affected Rule Keys") ?? "", RULE_KEY);
554
+ const regressionFloor = splitCellTokens(values.get("Regression Floor") ?? "").map((value) => value.toLowerCase());
555
+ const scopeEvidence = splitCellTokens(values.get("Scope Evidence") ?? "");
556
+ if (affectedOperations.length === 0)
557
+ findings.push("Coverage Scope requires at least one Affected Operations entry");
558
+ if (affectedRuleKeys.length === 0)
559
+ findings.push("Coverage Scope requires at least one Affected Rule Keys entry");
560
+ if (scopeEvidence.length === 0)
561
+ findings.push("Coverage Scope requires non-empty Scope Evidence");
562
+ for (const required of REQUIRED_REGRESSION_FLOOR[changeClassification]) {
563
+ if (!regressionFloor.includes(required))
564
+ findings.push(`Coverage Scope regression floor is missing required entry: ${required}`);
565
+ }
566
+ return {
567
+ scope: {
568
+ changeClassification,
569
+ coveragePolicy,
570
+ affectedOperations,
571
+ affectedRuleKeys,
572
+ regressionFloor,
573
+ scopeEvidence,
574
+ completenessClaim: coveragePolicy === "full-contract" ? "affected-operations-full" : "affected-scope",
575
+ },
576
+ findings,
577
+ };
578
+ }
579
+ function operationFromEndpointField(endpointField) {
580
+ const match = /^([A-Z]+)\s+(\/\S+)/.exec(endpointField.trim());
581
+ return match ? `${match[1]} ${match[2]}` : undefined;
582
+ }
395
583
  function parseCoverageMatrix(readme) {
396
584
  const findings = [];
397
585
  const marker = /^##\s+(?:(?:\d+(?:\.\d+)*[.)]?\s+)?(?:Coverage Matrix|覆盖矩阵))\s*$/mi.exec(readme);
@@ -452,9 +640,17 @@ export async function analyzeBackendTestCaseCoverage(input) {
452
640
  const findings = [];
453
641
  const readmePath = path.join(input.workspaceRoot, "testcase", "md", "README.md");
454
642
  const readme = (await exists(readmePath)) ? await readFile(readmePath, "utf8") : "";
643
+ const coverageScope = parseCoverageScope(readme);
644
+ findings.push(...coverageScope.findings);
455
645
  const matrix = parseCoverageMatrix(readme);
456
646
  findings.push(...matrix.findings);
457
647
  const cases = await parseMarkdownCases(input.workspaceRoot);
648
+ for (const file of await markdownModuleFiles(input.workspaceRoot)) {
649
+ const markdown = await readFile(file, "utf8");
650
+ for (const match of markdown.matchAll(NON_CANONICAL_CASE_HEADING)) {
651
+ findings.push(`non-canonical Case ID heading must use BE-<MODULE>-<NNN>: ${match[1]}`);
652
+ }
653
+ }
458
654
  const caseById = new Map(cases.map((item) => [item.caseId, item]));
459
655
  const rules = [];
460
656
  const evidenceGaps = [];
@@ -518,10 +714,19 @@ export async function analyzeBackendTestCaseCoverage(input) {
518
714
  });
519
715
  }
520
716
  const extractedOpenApiRules = await extractBackendTestOpenApiRules(input);
521
- for (const extracted of extractedOpenApiRules) {
717
+ const knownOperations = orderedUnique(extractedOpenApiRules.map((rule) => operationFromEndpointField(rule.endpointField)).filter((value) => Boolean(value)));
718
+ for (const affectedOperation of coverageScope.scope.affectedOperations) {
719
+ if (knownOperations.length > 0 && !knownOperations.includes(affectedOperation))
720
+ findings.push(`Coverage Scope Affected Operation is absent from bound OpenAPI evidence: ${affectedOperation}`);
721
+ }
722
+ const requiredOpenApiRules = extractedOpenApiRules.filter((rule) => {
723
+ const operation = operationFromEndpointField(rule.endpointField);
724
+ return Boolean(operation && coverageScope.scope.affectedOperations.includes(operation));
725
+ });
726
+ for (const extracted of requiredOpenApiRules) {
522
727
  if (seenRules.has(extracted.ruleKey))
523
728
  continue;
524
- findings.push(`Coverage Matrix is missing documented OpenAPI rule: ${extracted.ruleKey} (${extracted.source})`);
729
+ findings.push(`Coverage Matrix is missing documented in-scope OpenAPI rule: ${extracted.ruleKey} (${extracted.source})`);
525
730
  seenRules.add(extracted.ruleKey);
526
731
  rules.push({
527
732
  ...extracted,
@@ -532,6 +737,11 @@ export async function analyzeBackendTestCaseCoverage(input) {
532
737
  status: "PARTIAL",
533
738
  });
534
739
  }
740
+ for (const affectedRuleKey of coverageScope.scope.affectedRuleKeys) {
741
+ if (!seenRules.has(affectedRuleKey))
742
+ findings.push(`Coverage Scope Affected Rule Key is absent from Coverage Matrix: ${affectedRuleKey}`);
743
+ }
744
+ const nonCrossCuttingOwners = new Map();
535
745
  for (const testCase of cases) {
536
746
  if (testCase.ruleKeys.length === 0)
537
747
  findings.push(`${testCase.caseId} missing section or entries: 覆盖规则`);
@@ -539,10 +749,29 @@ export async function analyzeBackendTestCaseCoverage(input) {
539
749
  findings.push(`${testCase.caseId} missing section or entries: 测试点`);
540
750
  if (testCase.scenarioTypes.length === 0)
541
751
  findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
752
+ if (testCase.declaredPrimarySymbols.length !== 1)
753
+ findings.push(`${testCase.caseId} must declare exactly one primary symbol`);
754
+ if (testCase.unclassifiedTestPoints.length > 0)
755
+ findings.push(`${testCase.caseId} has unclassified Test Points: ${testCase.unclassifiedTestPoints.join(", ")}`);
756
+ if (testCase.duplicateBindingTestPoints.length > 0)
757
+ findings.push(`${testCase.caseId} has duplicate Test Point bindings: ${testCase.duplicateBindingTestPoints.join(", ")}`);
758
+ const extraBindings = testCase.testPointBindings.filter((binding) => !testCase.testPoints.includes(binding.testPoint));
759
+ if (extraBindings.length > 0)
760
+ findings.push(`${testCase.caseId} binds undeclared Test Points: ${orderedUnique(extraBindings.map((binding) => binding.testPoint)).join(", ")}`);
761
+ for (const binding of testCase.testPointBindings.filter((item) => item.mode !== "cross-cutting")) {
762
+ const owners = nonCrossCuttingOwners.get(binding.testPoint) ?? [];
763
+ owners.push(testCase.caseId);
764
+ nonCrossCuttingOwners.set(binding.testPoint, owners);
765
+ }
542
766
  for (const ruleKey of testCase.ruleKeys)
543
767
  if (!seenRules.has(ruleKey))
544
768
  findings.push(`${testCase.caseId} references unknown Rule Key: ${ruleKey}`);
545
769
  }
770
+ for (const [testPoint, owners] of nonCrossCuttingOwners) {
771
+ const uniqueOwners = orderedUnique(owners);
772
+ if (uniqueOwners.length > 1)
773
+ findings.push(`${testPoint} is bound as a non-cross-cutting Test Point by multiple Cases: ${uniqueOwners.join(", ")}`);
774
+ }
546
775
  const explicitAc = input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-"));
547
776
  const coveredAc = new Set(cases.flatMap((item) => item.acIds));
548
777
  const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
@@ -553,8 +782,8 @@ export async function analyzeBackendTestCaseCoverage(input) {
553
782
  const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
554
783
  const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
555
784
  const facts = backendTestCaseCoverageFactsSchema.parse({
556
- schemaId: "backend-test-case-coverage-facts-v1",
557
- schemaVersion: 1,
785
+ schemaId: "backend-test-case-coverage-facts-v3",
786
+ schemaVersion: 3,
558
787
  taskId: input.sourceBinding.taskId,
559
788
  status: findings.length === 0 ? "PASS" : "FAIL",
560
789
  sourceBinding: input.sourceBinding,
@@ -562,10 +791,11 @@ export async function analyzeBackendTestCaseCoverage(input) {
562
791
  ...((await exists(readmePath)) ? [readmePath] : []),
563
792
  ...(await markdownModuleFiles(input.workspaceRoot)),
564
793
  ...(await Promise.all(input.sourceBinding.referencePaths.map(async (referencePath) => {
565
- const physicalPath = path.join(input.workspaceRoot, ".harness", "tasks", input.sourceBinding.taskId, referencePath);
794
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
566
795
  return (await exists(physicalPath)) ? physicalPath : "";
567
796
  }))).filter(Boolean),
568
797
  ]),
798
+ coverageScope: coverageScope.scope,
569
799
  summary: {
570
800
  explicitAcCount: explicitAc.length,
571
801
  coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
@@ -585,6 +815,11 @@ export async function analyzeBackendTestCaseCoverage(input) {
585
815
  coveredBusinessStateCount,
586
816
  gapCount: evidenceGaps.length,
587
817
  conflictCount: conflicts.length,
818
+ variantTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "variant").length, 0),
819
+ assertionTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "assertion").length, 0),
820
+ crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
821
+ unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
822
+ duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
588
823
  },
589
824
  cases: cases.map(({ body: _body, ...item }) => item),
590
825
  rules,
@@ -600,6 +835,17 @@ export async function analyzeBackendTestCaseCoverage(input) {
600
835
  "",
601
836
  facts.status,
602
837
  "",
838
+ "## Coverage Scope",
839
+ "",
840
+ `- Change Classification: ${facts.coverageScope.changeClassification}`,
841
+ `- Coverage Policy: ${facts.coverageScope.coveragePolicy}`,
842
+ `- Completeness Claim: ${facts.coverageScope.completenessClaim}`,
843
+ `- Affected Operations: ${facts.coverageScope.affectedOperations.join(", ") || "Unavailable"}`,
844
+ `- Affected Rule Keys: ${facts.coverageScope.affectedRuleKeys.join(", ") || "Unavailable"}`,
845
+ `- Regression Floor: ${facts.coverageScope.regressionFloor.join(", ") || "Unavailable"}`,
846
+ `- Scope Evidence: ${facts.coverageScope.scopeEvidence.join(", ") || "Unavailable"}`,
847
+ ...(facts.coverageScope.completenessClaim === "affected-scope" ? ["- Coverage is scoped to affected operations; it is not whole-API completeness."] : ["- Coverage fully evaluates the declared affected operations; it is not whole-API completeness unless every operation is explicitly listed."]),
848
+ "",
603
849
  "## Coverage Summary",
604
850
  "",
605
851
  "| Dimension | Total | Covered |",
@@ -611,6 +857,11 @@ export async function analyzeBackendTestCaseCoverage(input) {
611
857
  `| Boundary | ${summary.boundaryPointCount} | ${summary.coveredBoundaryPointCount} |`,
612
858
  `| Format | ${summary.formatClassCount} | ${summary.coveredFormatClassCount} |`,
613
859
  `| Business State | ${summary.businessStateCount} | ${summary.coveredBusinessStateCount} |`,
860
+ `| Variant bindings | ${summary.variantTestPointCount} | ${summary.variantTestPointCount} |`,
861
+ `| Assertion bindings | ${summary.assertionTestPointCount} | ${summary.assertionTestPointCount} |`,
862
+ `| Cross-cutting bindings | ${summary.crossCuttingTestPointCount} | ${summary.crossCuttingTestPointCount} |`,
863
+ `| Unclassified bindings | ${summary.unclassifiedTestPointCount} | 0 |`,
864
+ `| Duplicate bindings | ${summary.duplicateBindingTestPointCount} | 0 |`,
614
865
  "",
615
866
  "## Product Requirement Coverage",
616
867
  "",
@@ -658,6 +909,11 @@ function symbolCaseId(symbol) {
658
909
  const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
659
910
  return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
660
911
  }
912
+ function metadataTestPoints(region, label) {
913
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
914
+ const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
915
+ return orderedUnique(match?.[1]?.match(/\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []);
916
+ }
661
917
  function pytestFunctionRegionStart(source, functionIndex) {
662
918
  let regionStart = source.lastIndexOf("\n", functionIndex - 1) + 1;
663
919
  let cursor = regionStart;
@@ -672,24 +928,40 @@ function pytestFunctionRegionStart(source, functionIndex) {
672
928
  }
673
929
  return regionStart;
674
930
  }
931
+ function pytestParameterCollections(source) {
932
+ const collections = new Map();
933
+ for (const match of source.matchAll(/^([A-Z][A-Z0-9_]*)\s*=\s*\[([\s\S]*?)^\]/gm)) {
934
+ collections.set(match[1], orderedUnique([...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])));
935
+ }
936
+ return collections;
937
+ }
675
938
  function pytestSymbols(script, source) {
676
939
  const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
677
940
  const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
941
+ const parameterCollections = pytestParameterCollections(source);
678
942
  return matches.map((match, index) => {
679
943
  const regionStart = regionStarts[index];
680
944
  const regionEnd = regionStarts[index + 1] ?? source.length;
681
- const region = source.slice(regionStart, regionEnd);
945
+ const region = source.slice(match.index, regionEnd);
946
+ const decorators = source.slice(regionStart, match.index);
682
947
  const ids = caseIds(region);
683
948
  const fromSymbol = symbolCaseId(match[2]);
684
949
  if (fromSymbol)
685
950
  ids.unshift(fromSymbol);
686
- const parameterIds = orderedUnique([...region.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]));
951
+ const parameterIds = orderedUnique([
952
+ ...[...decorators.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]),
953
+ ...[...parameterCollections.entries()].flatMap(([name, values]) => new RegExp(`\\b${name}\\b`).test(decorators) ? values : []),
954
+ ]);
955
+ const assertionTestPoints = metadataTestPoints(region, "Assertion-Test-Points");
956
+ const crossCuttingTestPoints = metadataTestPoints(region, "Cross-Cutting-Test-Points");
687
957
  return {
688
958
  script,
689
959
  symbol: match[2],
690
960
  caseIds: orderedUnique(ids),
691
- testPoints: orderedUnique(region.match(/\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []),
961
+ testPoints: orderedUnique([...parameterIds, ...assertionTestPoints, ...crossCuttingTestPoints]),
692
962
  parameterIds,
963
+ assertionTestPoints,
964
+ crossCuttingTestPoints,
693
965
  };
694
966
  });
695
967
  }
@@ -715,41 +987,69 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
715
987
  const actualScripts = orderedUnique(refs.map((item) => item.script));
716
988
  const symbols = orderedUnique(refs.map((item) => item.symbol));
717
989
  const parameterIds = orderedUnique(refs.flatMap((item) => item.parameterIds));
718
- const observedPoints = orderedUnique(refs.flatMap((item) => item.testPoints));
719
- let cardinality = refs.length === 0 ? "1:0" : refs.length === 1 ? "1:1" : "1:N";
990
+ const assertionBindings = orderedUnique(refs.flatMap((item) => item.assertionTestPoints));
991
+ const crossCuttingBindings = orderedUnique(refs.flatMap((item) => item.crossCuttingTestPoints));
992
+ const variantTestPoints = testCase.testPointBindings.filter((item) => item.mode === "variant").map((item) => item.testPoint);
993
+ const assertionTestPoints = testCase.testPointBindings.filter((item) => item.mode === "assertion").map((item) => item.testPoint);
994
+ const crossCuttingTestPoints = testCase.testPointBindings.filter((item) => item.mode === "cross-cutting").map((item) => item.testPoint);
995
+ const mappedTestPoints = orderedUnique([
996
+ ...variantTestPoints.filter((point) => parameterIds.includes(point)),
997
+ ...assertionTestPoints.filter((point) => assertionBindings.includes(point)),
998
+ ...crossCuttingTestPoints.filter((point) => crossCuttingBindings.includes(point)),
999
+ ]);
1000
+ const declaredPrimarySymbol = testCase.declaredPrimarySymbols[0];
1001
+ const cardinality = refs.length === 0 ? "1:0" : refs.length === 1 ? "1:1" : "1:N";
720
1002
  let status = refs.length === 0 ? "MISSING_PYTEST" : refs.length > 1 ? "MULTIPLE_PYTEST" : "EXACT_1_TO_1";
721
1003
  const entryFindings = [];
722
1004
  if (refs.length === 0)
723
1005
  entryFindings.push(`${testCase.caseId} has no pytest symbol`);
724
1006
  if (refs.length > 1)
725
1007
  entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
726
- if (!declared || declared !== expected || actualScripts.some((script) => script !== expected)) {
1008
+ if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
727
1009
  if (refs.length > 0 && status === "EXACT_1_TO_1")
728
1010
  status = "SCRIPT_MISMATCH";
729
- entryFindings.push(`${testCase.caseId} script mapping mismatch: declared=${declared ?? "none"}, expected=${expected}, actual=${actualScripts.join(", ") || "none"}`);
1011
+ entryFindings.push(`${testCase.caseId} script/primary-symbol mapping mismatch: declaredScript=${declared ?? "none"}, expectedScript=${expected}, actual=${actualScripts.join(", ") || "none"}, declaredPrimarySymbol=${declaredPrimarySymbol ?? "none"}, actualSymbols=${symbols.join(", ") || "none"}`);
730
1012
  }
731
- const missingPoints = testCase.testPoints.filter((point) => {
732
- if (testCase.testPoints.length > 1)
733
- return !parameterIds.includes(point);
734
- return !observedPoints.includes(point) && !parameterIds.includes(point);
735
- });
736
- const extraPoints = parameterIds.filter((point) => !testCase.testPoints.includes(point));
737
- if (missingPoints.length > 0) {
1013
+ const missingVariant = variantTestPoints.filter((point) => !parameterIds.includes(point));
1014
+ const missingAssertion = assertionTestPoints.filter((point) => !assertionBindings.includes(point));
1015
+ const missingCrossCutting = crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
1016
+ const extraPoints = orderedUnique([...parameterIds, ...assertionBindings, ...crossCuttingBindings]).filter((point) => !testCase.testPoints.includes(point));
1017
+ if (testCase.unclassifiedTestPoints.length > 0 || testCase.duplicateBindingTestPoints.length > 0) {
738
1018
  if (refs.length > 0 && status === "EXACT_1_TO_1")
739
- status = "PARAMETER_POINT_MISSING";
740
- entryFindings.push(`${testCase.caseId} missing pytest Test Point IDs: ${missingPoints.join(", ")}`);
1019
+ status = "TEST_POINT_BINDING_DUPLICATE";
1020
+ entryFindings.push(`${testCase.caseId} invalid Markdown Test Point bindings: unclassified=${testCase.unclassifiedTestPoints.join(", ") || "none"}; duplicate=${testCase.duplicateBindingTestPoints.join(", ") || "none"}`);
1021
+ }
1022
+ if (missingVariant.length > 0) {
1023
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1024
+ status = "VARIANT_PARAMETER_MISSING";
1025
+ entryFindings.push(`${testCase.caseId} missing variant pytest parameter IDs: ${missingVariant.join(", ")}`);
1026
+ }
1027
+ if (missingAssertion.length > 0) {
1028
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1029
+ status = "ASSERTION_BINDING_MISSING";
1030
+ entryFindings.push(`${testCase.caseId} missing assertion docstring bindings: ${missingAssertion.join(", ")}`);
1031
+ }
1032
+ if (missingCrossCutting.length > 0) {
1033
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1034
+ status = "CROSS_CUTTING_EVIDENCE_MISSING";
1035
+ entryFindings.push(`${testCase.caseId} missing cross-cutting evidence bindings: ${missingCrossCutting.join(", ")}`);
741
1036
  }
742
1037
  if (extraPoints.length > 0) {
743
1038
  if (refs.length > 0 && status === "EXACT_1_TO_1")
744
- status = "PARAMETER_POINT_EXTRA";
745
- entryFindings.push(`${testCase.caseId} has extra pytest parameter IDs: ${extraPoints.join(", ")}`);
1039
+ status = "TEST_POINT_BINDING_EXTRA";
1040
+ entryFindings.push(`${testCase.caseId} has extra pytest Test Point bindings: ${extraPoints.join(", ")}`);
746
1041
  }
747
1042
  findings.push(...entryFindings);
748
1043
  entries.push({
749
1044
  markdownModule: testCase.markdownPath,
750
1045
  caseId: testCase.caseId,
751
1046
  testPoints: testCase.testPoints,
1047
+ variantTestPoints,
1048
+ assertionTestPoints,
1049
+ crossCuttingTestPoints,
1050
+ mappedTestPoints,
752
1051
  ...(declared ? { declaredScript: declared } : {}),
1052
+ ...(declaredPrimarySymbol ? { declaredPrimarySymbol } : {}),
753
1053
  expectedScript: expected,
754
1054
  actualScripts,
755
1055
  pytestSymbols: symbols,
@@ -772,6 +1072,10 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
772
1072
  markdownModule: "—",
773
1073
  ...(unknownId ? { caseId: unknownId } : {}),
774
1074
  testPoints: symbol.testPoints,
1075
+ variantTestPoints: [],
1076
+ assertionTestPoints: [],
1077
+ crossCuttingTestPoints: [],
1078
+ mappedTestPoints: [],
775
1079
  expectedScript: symbol.script,
776
1080
  actualScripts: [symbol.script],
777
1081
  pytestSymbols: [symbol.symbol],
@@ -788,8 +1092,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
788
1092
  }).length;
789
1093
  const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
790
1094
  const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
791
- schemaId: "backend-test-markdown-pytest-correspondence-facts-v1",
792
- schemaVersion: 1,
1095
+ schemaId: "backend-test-markdown-pytest-correspondence-facts-v2",
1096
+ schemaVersion: 2,
793
1097
  taskId: input.taskId,
794
1098
  status: findings.length === 0 ? "PASS" : "FAIL",
795
1099
  inputFiles: await inputFileFacts(input.workspaceRoot, [
@@ -802,12 +1106,18 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
802
1106
  exactModuleCount,
803
1107
  markdownCaseCount: cases.length,
804
1108
  exactCorrespondenceCount: caseEntries.filter((entry) => entry.status === "EXACT_1_TO_1").length,
1109
+ primarySymbolCount: orderedUnique(caseEntries.flatMap((entry) => entry.pytestSymbols)).length,
805
1110
  missingPytestCount: caseEntries.filter((entry) => entry.status === "MISSING_PYTEST").length,
806
1111
  multiplePytestCount: caseEntries.filter((entry) => entry.status === "MULTIPLE_PYTEST").length,
807
1112
  extraPytestCount: entries.filter((entry) => entry.cardinality === "0:1").length,
808
1113
  scriptMismatchCount: caseEntries.filter((entry) => entry.status === "SCRIPT_MISMATCH").length,
809
1114
  testPointCount: cases.reduce((sum, item) => sum + item.testPoints.length, 0),
810
- mappedTestPointCount: caseEntries.reduce((sum, entry) => sum + entry.testPoints.filter((point) => entry.parameterIds.includes(point) || (entry.testPoints.length === 1 && entry.status === "EXACT_1_TO_1")).length, 0),
1115
+ mappedTestPointCount: caseEntries.reduce((sum, entry) => sum + entry.mappedTestPoints.length, 0),
1116
+ variantTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "variant").length, 0),
1117
+ assertionTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "assertion").length, 0),
1118
+ crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
1119
+ unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
1120
+ duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
811
1121
  },
812
1122
  entries,
813
1123
  findings: orderedUnique(findings),
@@ -825,16 +1135,22 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
825
1135
  `- Exact module mappings: ${facts.summary.exactModuleCount}`,
826
1136
  `- Markdown Cases: ${facts.summary.markdownCaseCount}`,
827
1137
  `- Exact 1:1: ${facts.summary.exactCorrespondenceCount}`,
1138
+ `- Primary pytest symbols: ${facts.summary.primarySymbolCount}`,
828
1139
  `- Missing pytest: ${facts.summary.missingPytestCount}`,
829
1140
  `- Multiple pytest: ${facts.summary.multiplePytestCount}`,
830
1141
  `- Extra pytest: ${facts.summary.extraPytestCount}`,
831
1142
  `- Test Points: ${facts.summary.mappedTestPointCount}/${facts.summary.testPointCount}`,
1143
+ `- Variant Test Points: ${facts.summary.variantTestPointCount}`,
1144
+ `- Assertion Test Points: ${facts.summary.assertionTestPointCount}`,
1145
+ `- Cross-Cutting Test Points: ${facts.summary.crossCuttingTestPointCount}`,
1146
+ `- Unclassified Test Points: ${facts.summary.unclassifiedTestPointCount}`,
1147
+ `- Duplicate Test Point Bindings: ${facts.summary.duplicateBindingTestPointCount}`,
832
1148
  "",
833
1149
  "## Correspondence Matrix",
834
1150
  "",
835
- "| Markdown Module | Case ID | Test Points | Declared Script | Expected Script | Actual Script | Pytest Symbol | Parameter IDs | Cardinality | Status |",
836
- "|---|---|---|---|---|---|---|---|---|---|",
837
- ...facts.entries.map((entry) => `| ${entry.markdownModule} | ${entry.caseId ?? "—"} | ${entry.testPoints.join(", ") || "—"} | ${entry.declaredScript ?? "—"} | ${entry.expectedScript} | ${entry.actualScripts.join(", ") || "—"} | ${entry.pytestSymbols.join(", ") || "—"} | ${entry.parameterIds.join(", ") || "—"} | ${entry.cardinality} | ${entry.status} |`),
1151
+ "| Markdown Module | Case ID | Variant | Assertion | Cross-Cutting | Mapped | Declared Script | Primary Symbol | Actual Script | Pytest Symbol | Parameter IDs | Cardinality | Status |",
1152
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|",
1153
+ ...facts.entries.map((entry) => `| ${entry.markdownModule} | ${entry.caseId ?? "—"} | ${entry.variantTestPoints.join(", ") || "—"} | ${entry.assertionTestPoints.join(", ") || "—"} | ${entry.crossCuttingTestPoints.join(", ") || "—"} | ${entry.mappedTestPoints.join(", ") || "—"} | ${entry.declaredScript ?? "—"} | ${entry.declaredPrimarySymbol ?? "—"} | ${entry.actualScripts.join(", ") || "—"} | ${entry.pytestSymbols.join(", ") || "—"} | ${entry.parameterIds.join(", ") || "—"} | ${entry.cardinality} | ${entry.status} |`),
838
1154
  "",
839
1155
  "## Findings",
840
1156
  "",
@@ -928,6 +1244,7 @@ export async function materializeBackendTestCaseManifestFromFacts(input) {
928
1244
  ...base,
929
1245
  materializationStatus,
930
1246
  sourceFactsIssues,
1247
+ coverageScope: coverageResult.value?.coverageScope,
931
1248
  ruleCoverageSummary: coverageResult.value?.summary,
932
1249
  correspondenceSummary: correspondenceResult.value?.summary,
933
1250
  artifactRefs: {