@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.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 (83) hide show
  1. package/CHANGELOG.md +38 -17
  2. package/dist/application/task-lifecycle/advance.js +254 -4
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/commands/init-upgrade.js +32 -1
  6. package/dist/commands/init.js +94 -3
  7. package/dist/executors/shell-executor.js +4 -91
  8. package/dist/executors/shell-write-guard.js +26 -8
  9. package/dist/shared/operator/capabilities.js +72 -42
  10. package/dist/shared/resilient-git.js +133 -0
  11. package/dist/task/source-prepare/artifact-meta.js +137 -0
  12. package/dist/task/source-prepare/index.js +2 -0
  13. package/dist/task/source-prepare/parse-intent.js +58 -10
  14. package/dist/task/source-prepare/prepare.js +180 -16
  15. package/dist/task/source-prepare/reference-integrity.js +18 -2
  16. package/dist/task/source-prepare/semantic-intake.js +404 -0
  17. package/dist/worker/console/app-data.js +2 -0
  18. package/dist/worker/console/chat/chat-event-store.js +190 -25
  19. package/dist/worker/console/chat/model-resolver.js +17 -0
  20. package/dist/worker/console/chat/pi-console-config.js +250 -32
  21. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  22. package/dist/worker/console/chat/resource-loader.js +5 -4
  23. package/dist/worker/console/chat/routes.js +495 -157
  24. package/dist/worker/console/chat/runtime-context.js +48 -12
  25. package/dist/worker/console/chat/runtime-selection.js +59 -0
  26. package/dist/worker/console/chat/session-store.js +39 -0
  27. package/dist/worker/console/chat/shortcuts.js +1 -0
  28. package/dist/worker/console/chat/tool-adapter.js +9 -3
  29. package/dist/worker/console/chat/tools.js +5 -1
  30. package/dist/worker/console/dag-execution-receipt.js +380 -0
  31. package/dist/worker/console/operator-actions.js +559 -68
  32. package/dist/worker/console/server.js +8 -15
  33. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  34. package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
  35. package/dist/worker/console/static/index.html +2 -2
  36. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  37. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  38. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  39. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  40. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  41. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  42. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  43. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  44. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  45. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  46. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  47. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  48. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  49. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  50. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  51. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  52. package/dist/worker/delivery/final-verification.js +13 -5
  53. package/dist/worker/delivery/package.js +31 -19
  54. package/dist/worker/delivery/verification-bundle.js +6 -4
  55. package/dist/worker/observe/static/operator-chrome.css +5 -2
  56. package/dist/worker/observe/static/operator-chrome.js +6 -1
  57. package/dist/worker/observe/static/styles.css +39 -9
  58. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  59. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  60. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  61. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  62. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  63. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  64. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  65. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  66. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  67. package/dist/workflows/dag/init-hybrid.js +34 -46
  68. package/dist/workflows/dag/types.js +0 -7
  69. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  70. package/docs/templates/backend-test-dag.json +29 -32
  71. package/harness.json +1 -1
  72. package/package.json +1 -1
  73. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  74. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  75. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  76. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  77. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  78. package/skills/loop-agent/references/command-reference.md +3 -1
  79. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  80. package/skills/loop-agent/references/task-workflow.md +4 -0
  81. package/dist/worker/console/chat/instruction-skills.js +0 -217
  82. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  83. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -1,5 +1,4 @@
1
1
  import { createHash } from "node:crypto";
2
- import { spawnSync } from "node:child_process";
3
2
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { z } from "zod";
@@ -9,15 +8,7 @@ const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
9
8
  const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
10
9
  const NON_CANONICAL_CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-(?:\d{2}|\d{2,3}[A-Z]+))\b.*$/gm;
11
10
  const AC_ID_IN_TEXT = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
12
- const SOURCE_RULE_ID_IN_TEXT = /\b(?:AC|REQ|BR)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
13
- const SOURCE_ERROR_CODE_IN_TEXT = /\b[A-Z][0-9]{5,}\b/g;
14
- const JSON_FIELD_IN_TEXT = /["']([A-Za-z][A-Za-z0-9_]*)["']\s*:/g;
15
- const DTO_FIELD_IN_TEXT = /\b(?:private|public|protected)\s+(?:static\s+)?(?:final\s+)?[A-Za-z_$][A-Za-z0-9_$<>,.?\[\] ]*\s+([a-z][A-Za-z0-9_]*)\s*[;=]/g;
16
- const NATURAL_BUSINESS_RULE_KEYWORDS = /(?:事务|原子性|回滚|幂等|唯一性|唯一|状态转换|状态流转|权限|角色|租户|跨字段|transaction|atomic|rollback|idempoten|unique|state transition|authorization|permission|tenant|cross-field)/i;
17
- const NATURAL_BUSINESS_RULE_NORMATIVE = /(?:必须|不得|禁止|仅当|应当|需要|不能|保证|must|shall|required|must not|only when|guarantee)/i;
18
- // Token form is used for section scanning; anchored form validates full cells.
19
- const RULE_KEY_TOKEN = /(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*|[A-Z][0-9]{5,}/;
20
- const RULE_KEY = new RegExp(`^(?:${RULE_KEY_TOKEN.source})$`);
11
+ const RULE_KEY = /^(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
21
12
  const TEST_POINT = /^TP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
22
13
  const COVERAGE_HEADERS = [
23
14
  "Rule Key",
@@ -96,13 +87,6 @@ const coverageCaseSchema = z.object({
96
87
  scenarioTypes: z.array(z.string()),
97
88
  declaredScripts: z.array(z.string()),
98
89
  declaredPrimarySymbols: z.array(z.string()),
99
- payloadContract: z.object({
100
- declared: z.boolean(),
101
- noPayload: z.boolean(),
102
- requiredPaths: z.array(z.string()),
103
- allowedPaths: z.array(z.string()),
104
- enumValues: z.record(z.string(), z.array(z.string())),
105
- }).strict(),
106
90
  }).strict();
107
91
  const coverageRuleSchema = z.object({
108
92
  ruleKey: z.string().min(1),
@@ -118,23 +102,6 @@ const coverageRuleSchema = z.object({
118
102
  missingTestPoints: z.array(z.string()),
119
103
  status: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
120
104
  }).strict();
121
- const sourceCoverageFactSchema = z.object({
122
- kind: z.enum(["requirement-id", "error-code", "dto-field", "business-rule"]),
123
- key: z.string().min(1),
124
- sourcePath: z.string().min(1),
125
- sourceSha256: sha256Schema,
126
- lineStart: z.number().int().positive(),
127
- lineEnd: z.number().int().positive(),
128
- quote: z.string().min(1).max(1000),
129
- covered: z.boolean(),
130
- }).strict();
131
- const sourceCoverageSchema = z.object({
132
- sourceFactCount: z.number().int().min(0),
133
- coveredSourceFactCount: z.number().int().min(0),
134
- residualSourceFactCount: z.number().int().min(0),
135
- facts: z.array(sourceCoverageFactSchema),
136
- residual: z.array(sourceCoverageFactSchema),
137
- }).strict();
138
105
  const coverageScopeSchema = z.object({
139
106
  changeClassification: z.enum(CHANGE_CLASSIFICATIONS),
140
107
  coveragePolicy: z.enum(COVERAGE_POLICIES),
@@ -145,14 +112,13 @@ const coverageScopeSchema = z.object({
145
112
  completenessClaim: z.enum(["affected-operations-full", "affected-scope"]),
146
113
  }).strict();
147
114
  export const backendTestCaseCoverageFactsSchema = z.object({
148
- schemaId: z.literal("backend-test-case-coverage-facts-v4"),
149
- schemaVersion: z.literal(4),
115
+ schemaId: z.literal("backend-test-case-coverage-facts-v3"),
116
+ schemaVersion: z.literal(3),
150
117
  taskId: z.string().min(1),
151
118
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
152
119
  sourceBinding: sourceBindingSchema,
153
120
  inputFiles: z.array(inputFileSchema),
154
121
  coverageScope: coverageScopeSchema,
155
- sourceCoverage: sourceCoverageSchema,
156
122
  summary: z.object({
157
123
  explicitAcCount: z.number().int().min(0),
158
124
  coveredAcCount: z.number().int().min(0),
@@ -211,24 +177,12 @@ const correspondenceEntrySchema = z.object({
211
177
  "CROSS_CUTTING_EVIDENCE_MISSING",
212
178
  "TEST_POINT_BINDING_DUPLICATE",
213
179
  "TEST_POINT_BINDING_EXTRA",
214
- "PAYLOAD_CONTRACT_UNAVAILABLE",
215
- "PAYLOAD_SHAPE_MISMATCH",
216
180
  ]),
217
- payloadAssessment: z.object({
218
- status: z.enum(["SAFE", "UNSAFE", "UNAVAILABLE"]),
219
- requiredPaths: z.array(z.string()),
220
- allowedPaths: z.array(z.string()),
221
- observedPaths: z.array(z.string()),
222
- missingPaths: z.array(z.string()),
223
- unexpectedPaths: z.array(z.string()),
224
- enumMismatches: z.array(z.string()),
225
- }).strict(),
226
- executionSignature: z.string().min(1).optional(),
227
181
  findings: z.array(z.string()),
228
182
  }).strict();
229
183
  export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
230
- schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v3"),
231
- schemaVersion: z.literal(3),
184
+ schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v2"),
185
+ schemaVersion: z.literal(2),
232
186
  taskId: z.string().min(1),
233
187
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
234
188
  inputFiles: z.array(inputFileSchema),
@@ -249,10 +203,6 @@ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
249
203
  crossCuttingTestPointCount: z.number().int().min(0),
250
204
  unclassifiedTestPointCount: z.number().int().min(0),
251
205
  duplicateBindingTestPointCount: z.number().int().min(0),
252
- payloadSafeCount: z.number().int().min(0),
253
- payloadUnsafeCount: z.number().int().min(0),
254
- payloadUnavailableCount: z.number().int().min(0),
255
- duplicateExecutionSignatureCount: z.number().int().min(0),
256
206
  }).strict(),
257
207
  entries: z.array(correspondenceEntrySchema),
258
208
  findings: z.array(z.string()),
@@ -286,64 +236,6 @@ function openApiTestPoints(field, dimension, values = []) {
286
236
  return [`${prefix}-VALID-CLASS`, `${prefix}-VALID-MIXED`, `${prefix}-UPPERCASE`, `${prefix}-WHITESPACE`, `${prefix}-PUNCTUATION`, `${prefix}-SLASH`, `${prefix}-EMOJI`, `${prefix}-CONTROL`];
287
237
  return [`${prefix}-PRESENT`, `${prefix}-MISSING`, `${prefix}-NULL`, `${prefix}-WRONG-TYPE`];
288
238
  }
289
- async function collectSourceCoverageFacts(input) {
290
- const paths = orderedUnique([
291
- input.sourceBinding.requirementPath,
292
- ...input.sourceBinding.referencePaths,
293
- ]);
294
- const facts = [];
295
- const seen = new Set();
296
- for (const sourcePath of paths) {
297
- const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, sourcePath);
298
- let text;
299
- try {
300
- text = await readFile(physicalPath, "utf8");
301
- }
302
- catch {
303
- continue;
304
- }
305
- const sourceSha256 = createHash("sha256").update(await readFile(physicalPath)).digest("hex");
306
- const lines = text.split(/\r?\n/);
307
- const add = (kind, key, lineIndex, quote) => {
308
- const normalizedQuote = quote.trim().slice(0, 1000);
309
- if (!normalizedQuote)
310
- return;
311
- const identity = `${kind}:${key}:${sourcePath}:${lineIndex + 1}`;
312
- if (seen.has(identity))
313
- return;
314
- seen.add(identity);
315
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
316
- const covered = kind === "business-rule"
317
- ? input.generatedMarkdown.includes(normalizedQuote)
318
- : new RegExp(`\\b${escaped}\\b`).test(input.generatedMarkdown);
319
- facts.push({
320
- kind,
321
- key,
322
- sourcePath,
323
- sourceSha256,
324
- lineStart: lineIndex + 1,
325
- lineEnd: lineIndex + 1,
326
- quote: normalizedQuote,
327
- covered,
328
- });
329
- };
330
- lines.forEach((line, lineIndex) => {
331
- for (const key of line.match(SOURCE_RULE_ID_IN_TEXT) ?? [])
332
- add("requirement-id", key, lineIndex, line);
333
- for (const key of line.match(SOURCE_ERROR_CODE_IN_TEXT) ?? [])
334
- add("error-code", key, lineIndex, line);
335
- for (const match of line.matchAll(JSON_FIELD_IN_TEXT))
336
- add("dto-field", match[1], lineIndex, line);
337
- for (const match of line.matchAll(DTO_FIELD_IN_TEXT))
338
- add("dto-field", match[1], lineIndex, line);
339
- const trimmed = line.trim();
340
- if (trimmed.length >= 8 && trimmed.length <= 1000 && !/^\s*(?:#|\||[-:]{3,})/.test(trimmed) && NATURAL_BUSINESS_RULE_KEYWORDS.test(trimmed) && NATURAL_BUSINESS_RULE_NORMATIVE.test(trimmed)) {
341
- add("business-rule", `BR-SOURCE-${sourceSha256.slice(0, 8).toUpperCase()}-L${lineIndex + 1}`, lineIndex, trimmed);
342
- }
343
- });
344
- }
345
- return facts.sort((left, right) => left.kind.localeCompare(right.kind) || left.key.localeCompare(right.key) || left.sourcePath.localeCompare(right.sourcePath) || left.lineStart - right.lineStart);
346
- }
347
239
  function resolveBoundSourcePath(workspaceRoot, taskId, sourcePath) {
348
240
  if (path.isAbsolute(sourcePath))
349
241
  return sourcePath;
@@ -493,14 +385,8 @@ function sectionBody(body, aliases) {
493
385
  }
494
386
  function listTokens(body, aliases, pattern) {
495
387
  const section = sectionBody(body, aliases).replaceAll("`", "");
496
- // Prefer dedicated token patterns; never strip ^/$ from alternation-based anchors
497
- // (that would turn `^A$|^B$` into `A$|^B` and break scanning).
498
- const tokenSource = pattern === RULE_KEY
499
- ? RULE_KEY_TOKEN.source
500
- : pattern === TEST_POINT
501
- ? TEST_POINT.source.replace(/^\^/, "").replace(/\$$/, "")
502
- : pattern.source.replace(/^\^/, "").replace(/\$$/, "");
503
- return orderedUnique(section.match(new RegExp(tokenSource, "g")) ?? []);
388
+ const source = pattern.source.replace(/^\^/, "").replace(/\$$/, "");
389
+ return orderedUnique(section.match(new RegExp(source, "g")) ?? []);
504
390
  }
505
391
  function scenarioTypes(body) {
506
392
  return orderedUnique(sectionBody(body, CASE_SECTION_ALIASES.scenarioTypes)
@@ -537,40 +423,8 @@ function testPointBindingFacts(body, testPoints) {
537
423
  }
538
424
  function declaredPrimarySymbols(body) {
539
425
  const value = automationLabelBody(body, ["primary symbol", "Primary Symbol", "主测试符号"]);
540
- if (/^(?:none|无|n\/a|not-applicable)$/i.test(value.trim()))
541
- return [];
542
426
  return orderedUnique(value.match(/\btest_[A-Za-z0-9_]+\b/g) ?? []);
543
427
  }
544
- function isEvidenceOnlyMarkdownCase(testCase) {
545
- const hasVariant = testCase.testPointBindings.some((item) => item.mode === "variant");
546
- const hasNonVariant = testCase.testPointBindings.some((item) => item.mode === "assertion" || item.mode === "cross-cutting");
547
- return (testCase.declaredScripts.length === 0 &&
548
- testCase.declaredPrimarySymbols.length === 0 &&
549
- !hasVariant &&
550
- hasNonVariant);
551
- }
552
- function payloadContract(body) {
553
- const contractValue = automationLabelBody(body, ["Payload Contract", "载荷合同"]);
554
- const requiredValue = automationLabelBody(body, ["Payload Required Paths", "载荷必需路径"]);
555
- const allowedValue = automationLabelBody(body, ["Payload Allowed Paths", "载荷允许路径"]);
556
- const enumValue = automationLabelBody(body, ["Payload Enum", "载荷枚举"]);
557
- const tokens = (value) => orderedUnique(value.split(/[,;,;]/).map((item) => item.trim()).filter((item) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(item)));
558
- const enumValues = {};
559
- for (const assignment of enumValue.split(/[,;,;]/).map((item) => item.trim()).filter(Boolean)) {
560
- const match = /^([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.+)$/.exec(assignment);
561
- if (!match)
562
- continue;
563
- enumValues[match[1]] = orderedUnique(match[2].split(/\||\//).map((item) => item.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean));
564
- }
565
- const noPayload = /^(?:none|无|not-applicable|n\/a)$/i.test(contractValue);
566
- return {
567
- declared: noPayload || requiredValue.length > 0 || allowedValue.length > 0 || enumValue.length > 0,
568
- noPayload,
569
- requiredPaths: tokens(requiredValue),
570
- allowedPaths: tokens(allowedValue),
571
- enumValues,
572
- };
573
- }
574
428
  function cleanTitle(heading, id) {
575
429
  return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
576
430
  }
@@ -612,7 +466,6 @@ async function parseMarkdownCases(workspaceRoot) {
612
466
  scenarioTypes: scenarioTypes(body),
613
467
  declaredScripts: declaredScripts(body),
614
468
  declaredPrimarySymbols: declaredPrimarySymbols(body),
615
- payloadContract: payloadContract(body),
616
469
  body,
617
470
  });
618
471
  }
@@ -630,17 +483,8 @@ function parseTableRow(line) {
630
483
  index += 1;
631
484
  }
632
485
  else if (char === "|") {
633
- const prev = trimmed[index - 1] ?? "";
634
- const next = trimmed[index + 1] ?? "";
635
- // In-cell enum/value pipes look like ACTIVE|ARCHIVED (alnum on both sides).
636
- // Column separators use " | " or touch markdown separator dashes (---|---).
637
- if (/[A-Za-z0-9_]/.test(prev) && /[A-Za-z0-9_]/.test(next)) {
638
- current += "|";
639
- }
640
- else {
641
- cells.push(current.trim());
642
- current = "";
643
- }
486
+ cells.push(current.trim());
487
+ current = "";
644
488
  }
645
489
  else {
646
490
  current += char;
@@ -843,10 +687,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
843
687
  if (!rowCaseIds.includes(testCase.caseId))
844
688
  findings.push(`${testCase.caseId} references ${ruleKey} but is absent from its Coverage Matrix Case IDs`);
845
689
  }
846
- const normalizedDeclaredStatus = /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus)
847
- ? declaredStatus
848
- : "PARTIAL";
849
- let status = normalizedDeclaredStatus;
690
+ let status = declaredStatus;
850
691
  if (status === "COVERED" && missingTestPoints.length > 0)
851
692
  status = "PARTIAL";
852
693
  if (missingTestPoints.length > 0)
@@ -866,7 +707,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
866
707
  rule: row.Rule || "unavailable",
867
708
  requiredTestPoints,
868
709
  caseIds: rowCaseIds,
869
- declaredStatus: normalizedDeclaredStatus,
710
+ declaredStatus: /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus) ? declaredStatus : "PARTIAL",
870
711
  coveredTestPoints,
871
712
  missingTestPoints,
872
713
  status,
@@ -908,12 +749,8 @@ export async function analyzeBackendTestCaseCoverage(input) {
908
749
  findings.push(`${testCase.caseId} missing section or entries: 测试点`);
909
750
  if (testCase.scenarioTypes.length === 0)
910
751
  findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
911
- if (isEvidenceOnlyMarkdownCase(testCase)) {
912
- // Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and only keep assertion/cross-cutting TPs.
913
- }
914
- else if (testCase.declaredPrimarySymbols.length !== 1) {
752
+ if (testCase.declaredPrimarySymbols.length !== 1)
915
753
  findings.push(`${testCase.caseId} must declare exactly one primary symbol`);
916
- }
917
754
  if (testCase.unclassifiedTestPoints.length > 0)
918
755
  findings.push(`${testCase.caseId} has unclassified Test Points: ${testCase.unclassifiedTestPoints.join(", ")}`);
919
756
  if (testCase.duplicateBindingTestPoints.length > 0)
@@ -935,28 +772,8 @@ export async function analyzeBackendTestCaseCoverage(input) {
935
772
  if (uniqueOwners.length > 1)
936
773
  findings.push(`${testPoint} is bound as a non-cross-cutting Test Point by multiple Cases: ${uniqueOwners.join(", ")}`);
937
774
  }
938
- const generatedMarkdown = [readme, ...cases.map((item) => item.body)].join("\n\n");
939
- const sourceCoverageFacts = await collectSourceCoverageFacts({
940
- workspaceRoot: input.workspaceRoot,
941
- sourceBinding: input.sourceBinding,
942
- generatedMarkdown,
943
- });
944
- const residualSourceFacts = sourceCoverageFacts.filter((fact) => !fact.covered);
945
- for (const fact of residualSourceFacts) {
946
- findings.push(`source coverage is missing ${fact.kind} ${fact.key} from ${fact.sourcePath}`);
947
- }
948
- const sourceRequirementIds = sourceCoverageFacts
949
- .filter((fact) => fact.kind === "requirement-id")
950
- .map((fact) => fact.key);
951
- const explicitAc = orderedUnique([
952
- ...input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-")),
953
- ...sourceRequirementIds.filter((id) => id.startsWith("AC-")),
954
- ]);
775
+ const explicitAc = input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-"));
955
776
  const coveredAc = new Set(cases.flatMap((item) => item.acIds));
956
- for (const acId of explicitAc) {
957
- if (!coveredAc.has(acId))
958
- findings.push(`explicit source AC is missing from Markdown Cases: ${acId}`);
959
- }
960
777
  const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
961
778
  const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
962
779
  const [formatClassCount, coveredFormatClassCount] = dimensionCounts(rules, /format|pattern|charset/);
@@ -965,8 +782,8 @@ export async function analyzeBackendTestCaseCoverage(input) {
965
782
  const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
966
783
  const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
967
784
  const facts = backendTestCaseCoverageFactsSchema.parse({
968
- schemaId: "backend-test-case-coverage-facts-v4",
969
- schemaVersion: 4,
785
+ schemaId: "backend-test-case-coverage-facts-v3",
786
+ schemaVersion: 3,
970
787
  taskId: input.sourceBinding.taskId,
971
788
  status: findings.length === 0 ? "PASS" : "FAIL",
972
789
  sourceBinding: input.sourceBinding,
@@ -979,13 +796,6 @@ export async function analyzeBackendTestCaseCoverage(input) {
979
796
  }))).filter(Boolean),
980
797
  ]),
981
798
  coverageScope: coverageScope.scope,
982
- sourceCoverage: {
983
- sourceFactCount: sourceCoverageFacts.length,
984
- coveredSourceFactCount: sourceCoverageFacts.length - residualSourceFacts.length,
985
- residualSourceFactCount: residualSourceFacts.length,
986
- facts: sourceCoverageFacts,
987
- residual: residualSourceFacts,
988
- },
989
799
  summary: {
990
800
  explicitAcCount: explicitAc.length,
991
801
  coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
@@ -1036,22 +846,6 @@ export async function analyzeBackendTestCaseCoverage(input) {
1036
846
  `- Scope Evidence: ${facts.coverageScope.scopeEvidence.join(", ") || "Unavailable"}`,
1037
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."]),
1038
848
  "",
1039
- "## Source Coverage Inventory",
1040
- "",
1041
- `- Source facts: ${facts.sourceCoverage.sourceFactCount}`,
1042
- `- Covered source facts: ${facts.sourceCoverage.coveredSourceFactCount}`,
1043
- `- Residual source facts: ${facts.sourceCoverage.residualSourceFactCount}`,
1044
- "",
1045
- "| Kind | Key | Source Anchor | Source SHA-256 | Exact Quote | Status |",
1046
- "|---|---|---|---|---|---|",
1047
- ...facts.sourceCoverage.facts.map((fact) => `| ${fact.kind} | ${fact.key} | ${fact.sourcePath}:L${fact.lineStart} | ${fact.sourceSha256} | ${fact.quote.replaceAll("|", "\\|")} | ${fact.covered ? "COVERED" : "MISSING_AFTER_SYNC"} |`),
1048
- "",
1049
- "## Residual Missing Source Scenarios",
1050
- "",
1051
- ...(facts.sourceCoverage.residual.length > 0
1052
- ? facts.sourceCoverage.residual.map((fact) => `- ${fact.kind} \`${fact.key}\` from \`${fact.sourcePath}:L${fact.lineStart}\` (sha256 \`${fact.sourceSha256}\`), quote: “${fact.quote}”: missing after the bounded Markdown synchronization round; no full-coverage claim is allowed.`)
1053
- : ["- None"]),
1054
- "",
1055
849
  "## Coverage Summary",
1056
850
  "",
1057
851
  "| Dimension | Total | Covered |",
@@ -1115,103 +909,6 @@ function symbolCaseId(symbol) {
1115
909
  const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
1116
910
  return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
1117
911
  }
1118
- function pythonPayloadShapes(source) {
1119
- const program = String.raw `
1120
- import ast,json,sys
1121
- source=sys.stdin.buffer.read().decode('utf-8')
1122
- tree=ast.parse(source)
1123
- GLOBAL_PAYLOADS={}
1124
- for top in tree.body:
1125
- if isinstance(top,ast.Assign):
1126
- names=[target.id for target in top.targets if isinstance(target,ast.Name)]
1127
- candidates=[]
1128
- for node in ast.walk(top.value):
1129
- if isinstance(node,ast.Call) and isinstance(node.func,ast.Attribute) and node.func.attr == 'param' and node.args and isinstance(node.args[0],ast.Dict): candidates.append(node.args[0])
1130
- if isinstance(top.value,ast.Dict) and any(name.lower() in {'payload','body','request_body','request_json','dto'} for name in names): candidates.append(top.value)
1131
- for name in names: GLOBAL_PAYLOADS[name]=candidates
1132
- FUNCTION_PAYLOADS={}
1133
- for top in tree.body:
1134
- if isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) and not top.name.startswith('test_'):
1135
- candidates=[]
1136
- assigned={}
1137
- for node in ast.walk(top):
1138
- if isinstance(node,ast.Assign) and isinstance(node.value,ast.Dict):
1139
- for target in node.targets:
1140
- if isinstance(target,ast.Name): assigned[target.id]=node.value
1141
- if isinstance(node,ast.Return):
1142
- if isinstance(node.value,ast.Dict): candidates.append(node.value)
1143
- elif isinstance(node.value,ast.Name) and node.value.id in assigned: candidates.append(assigned[node.value.id])
1144
- if candidates: FUNCTION_PAYLOADS[top.name]=candidates
1145
-
1146
- def literal(node):
1147
- if isinstance(node, ast.Constant) and isinstance(node.value, (str,int,float,bool,type(None))):
1148
- return repr(node.value)
1149
- return None
1150
-
1151
- def collect_dict(node, prefix, paths, values):
1152
- if not isinstance(node, ast.Dict): return
1153
- for key,value in zip(node.keys,node.values):
1154
- if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
1155
- current = key.value if not prefix else prefix + '.' + key.value
1156
- paths.add(current)
1157
- lv=literal(value)
1158
- if lv is not None: values.setdefault(current,set()).add(lv)
1159
- if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
1160
- elif isinstance(value,(ast.List,ast.Tuple)):
1161
- for item in value.elts:
1162
- if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
1163
-
1164
- def payload_nodes(fn):
1165
- result=[]
1166
- for dec in fn.decorator_list:
1167
- for node in ast.walk(dec):
1168
- if isinstance(node,ast.Call) and isinstance(node.func,ast.Attribute) and node.func.attr == 'param' and node.args:
1169
- if isinstance(node.args[0],ast.Dict): result.append(node.args[0])
1170
- elif isinstance(node,ast.Name) and node.id in GLOBAL_PAYLOADS:
1171
- result.extend(GLOBAL_PAYLOADS[node.id])
1172
- for node in ast.walk(fn):
1173
- if isinstance(node,ast.Assign):
1174
- names=[target.id for target in node.targets if isinstance(target,ast.Name)]
1175
- if any(name.lower() in {'payload','body','request_body','request_json','dto'} for name in names) and isinstance(node.value,ast.Dict): result.append(node.value)
1176
- if isinstance(node,ast.Call):
1177
- if isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1178
- result.extend(FUNCTION_PAYLOADS[node.func.id])
1179
- override_keys=[]; override_values=[]
1180
- for keyword in node.keywords:
1181
- if keyword.arg:
1182
- override_keys.append(ast.Constant(keyword.arg)); override_values.append(keyword.value)
1183
- if override_keys: result.append(ast.Dict(keys=override_keys,values=override_values))
1184
- for keyword in node.keywords:
1185
- if keyword.arg in {'json','data','payload','body','request_body'} and isinstance(keyword.value,ast.Dict): result.append(keyword.value)
1186
- return result
1187
-
1188
- out={}
1189
- for fn in ast.walk(tree):
1190
- if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
1191
- paths=set(); values={}
1192
- for node in payload_nodes(fn): collect_dict(node,'',paths,values)
1193
- out[fn.name]={'paths':sorted(paths),'values':{key:sorted(items) for key,items in sorted(values.items())}}
1194
- print(json.dumps(out,ensure_ascii=True))
1195
- `;
1196
- // Pass bytes and decode stdin explicitly as UTF-8 in Python. Clean-env
1197
- // Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
1198
- // source through that locale can create surrogateescape code points and make
1199
- // ast.parse fail with "surrogates not allowed".
1200
- const result = spawnSync("python", ["-c", program], { input: Buffer.from(source, "utf8"), encoding: "utf8", maxBuffer: 4 * 1024 * 1024, windowsHide: true });
1201
- if (result.status !== 0 || !result.stdout.trim())
1202
- return new Map();
1203
- try {
1204
- const parsed = JSON.parse(result.stdout);
1205
- return new Map(Object.entries(parsed));
1206
- }
1207
- catch {
1208
- return new Map();
1209
- }
1210
- }
1211
- function metadataCaseIds(region) {
1212
- return orderedUnique([...region.matchAll(/^\s*Case-ID\s*:\s*(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*)\s*$/gmi)]
1213
- .map((match) => canonicalCaseId(match[1])));
1214
- }
1215
912
  function metadataTestPoints(region, label) {
1216
913
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1217
914
  const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
@@ -1231,27 +928,6 @@ function pytestFunctionRegionStart(source, functionIndex) {
1231
928
  }
1232
929
  return regionStart;
1233
930
  }
1234
- function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
1235
- let cursor = source.indexOf("\n", signatureEnd);
1236
- if (cursor < 0)
1237
- return source.length;
1238
- cursor += 1;
1239
- const baseIndent = functionIndent.replaceAll("\t", " ").length;
1240
- while (cursor < source.length) {
1241
- const nextLine = source.indexOf("\n", cursor);
1242
- const lineEnd = nextLine < 0 ? source.length : nextLine;
1243
- const line = source.slice(cursor, lineEnd).replace(/\r$/, "");
1244
- if (line.trim()) {
1245
- const indentation = line.match(/^[ \t]*/)?.[0]?.replaceAll("\t", " ").length ?? 0;
1246
- if (indentation <= baseIndent)
1247
- return cursor;
1248
- }
1249
- if (nextLine < 0)
1250
- return source.length;
1251
- cursor = nextLine + 1;
1252
- }
1253
- return source.length;
1254
- }
1255
931
  function pytestParameterCollections(source) {
1256
932
  const collections = new Map();
1257
933
  for (const match of source.matchAll(/^([A-Z][A-Z0-9_]*)\s*=\s*\[([\s\S]*?)^\]/gm)) {
@@ -1263,13 +939,12 @@ function pytestSymbols(script, source) {
1263
939
  const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
1264
940
  const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
1265
941
  const parameterCollections = pytestParameterCollections(source);
1266
- const payloadShapes = pythonPayloadShapes(source);
1267
942
  return matches.map((match, index) => {
1268
943
  const regionStart = regionStarts[index];
1269
- const regionEnd = pytestFunctionBodyEnd(source, match.index + match[0].length, match[1]);
944
+ const regionEnd = regionStarts[index + 1] ?? source.length;
1270
945
  const region = source.slice(match.index, regionEnd);
1271
946
  const decorators = source.slice(regionStart, match.index);
1272
- const ids = metadataCaseIds(region);
947
+ const ids = caseIds(region);
1273
948
  const fromSymbol = symbolCaseId(match[2]);
1274
949
  if (fromSymbol)
1275
950
  ids.unshift(fromSymbol);
@@ -1279,7 +954,6 @@ function pytestSymbols(script, source) {
1279
954
  ]);
1280
955
  const assertionTestPoints = metadataTestPoints(region, "Assertion-Test-Points");
1281
956
  const crossCuttingTestPoints = metadataTestPoints(region, "Cross-Cutting-Test-Points");
1282
- const payloadShape = payloadShapes.get(match[2]) ?? { paths: [], values: {} };
1283
957
  return {
1284
958
  script,
1285
959
  symbol: match[2],
@@ -1288,51 +962,9 @@ function pytestSymbols(script, source) {
1288
962
  parameterIds,
1289
963
  assertionTestPoints,
1290
964
  crossCuttingTestPoints,
1291
- payloadPaths: payloadShape.paths,
1292
- payloadValues: payloadShape.values,
1293
965
  };
1294
966
  });
1295
967
  }
1296
- function executionSignature(testCase) {
1297
- const operation = (testCase.body.match(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[^\s`|)]+/i)?.[0] ?? "operation-unavailable").toUpperCase();
1298
- const inputPartition = [sectionBody(testCase.body, ["测试数据", "Test Data"]), testCase.scenarioTypes.join(" "), testCase.testPoints.join(" ")].join(" ");
1299
- const precondition = sectionBody(testCase.body, ["前置条件", "Preconditions", "Precondition"]);
1300
- const expectedOutcome = sectionBody(testCase.body, ["预期结果", "Expected Results", "Expected Result"]);
1301
- const normalized = [operation, inputPartition, precondition, expectedOutcome]
1302
- .map((value) => value.replaceAll(/\s+/g, " ").trim().toLowerCase())
1303
- .join("\n");
1304
- return createHash("sha256").update(normalized).digest("hex");
1305
- }
1306
- function assessPayloadContract(testCase, refs) {
1307
- const observedPaths = orderedUnique(refs.flatMap((item) => item.payloadPaths));
1308
- const requiredPaths = testCase.payloadContract.requiredPaths;
1309
- const allowedPaths = testCase.payloadContract.allowedPaths;
1310
- if (!testCase.payloadContract.declared) {
1311
- return { status: "UNAVAILABLE", requiredPaths, allowedPaths, observedPaths, missingPaths: [], unexpectedPaths: [], enumMismatches: [] };
1312
- }
1313
- const missingPaths = requiredPaths.filter((item) => !observedPaths.includes(item));
1314
- const unexpectedPaths = testCase.payloadContract.noPayload
1315
- ? observedPaths
1316
- : allowedPaths.length > 0
1317
- ? observedPaths.filter((item) => !allowedPaths.includes(item) && !allowedPaths.some((allowed) => item.startsWith(`${allowed}.`)))
1318
- : [];
1319
- const enumMismatches = [];
1320
- for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
1321
- const observed = orderedUnique(refs.flatMap((item) => item.payloadValues[field] ?? []).map((value) => value.replace(/^['"]|['"]$/g, "")));
1322
- for (const value of observed)
1323
- if (!allowed.includes(value))
1324
- enumMismatches.push(`${field}=${value}; allowed=${allowed.join("|")}`);
1325
- }
1326
- return {
1327
- status: missingPaths.length > 0 || unexpectedPaths.length > 0 || enumMismatches.length > 0 ? "UNSAFE" : "SAFE",
1328
- requiredPaths,
1329
- allowedPaths,
1330
- observedPaths,
1331
- missingPaths,
1332
- unexpectedPaths,
1333
- enumMismatches,
1334
- };
1335
- }
1336
968
  export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1337
969
  const cases = await parseMarkdownCases(input.workspaceRoot);
1338
970
  const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
@@ -1366,44 +998,21 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1366
998
  ...crossCuttingTestPoints.filter((point) => crossCuttingBindings.includes(point)),
1367
999
  ]);
1368
1000
  const declaredPrimarySymbol = testCase.declaredPrimarySymbols[0];
1369
- const evidenceOnly = isEvidenceOnlyMarkdownCase(testCase);
1370
1001
  const cardinality = refs.length === 0 ? "1:0" : refs.length === 1 ? "1:1" : "1:N";
1371
- let status = evidenceOnly && refs.length === 0
1372
- ? "EXACT_1_TO_1"
1373
- : refs.length === 0
1374
- ? "MISSING_PYTEST"
1375
- : refs.length > 1
1376
- ? "MULTIPLE_PYTEST"
1377
- : "EXACT_1_TO_1";
1002
+ let status = refs.length === 0 ? "MISSING_PYTEST" : refs.length > 1 ? "MULTIPLE_PYTEST" : "EXACT_1_TO_1";
1378
1003
  const entryFindings = [];
1379
- const payloadAssessment = assessPayloadContract(testCase, refs);
1380
- const signature = executionSignature(testCase);
1381
- if (evidenceOnly) {
1382
- if (refs.length > 0) {
1004
+ if (refs.length === 0)
1005
+ entryFindings.push(`${testCase.caseId} has no pytest symbol`);
1006
+ if (refs.length > 1)
1007
+ entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
1008
+ if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
1009
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1383
1010
  status = "SCRIPT_MISMATCH";
1384
- entryFindings.push(`${testCase.caseId} evidence-only Markdown Case must not materialize a business pytest symbol; actualSymbols=${symbols.join(", ") || "none"}`);
1385
- }
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"}`);
1386
1012
  }
1387
- else {
1388
- if (refs.length === 0)
1389
- entryFindings.push(`${testCase.caseId} has no pytest symbol`);
1390
- if (refs.length > 1)
1391
- entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
1392
- if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
1393
- if (refs.length > 0 && status === "EXACT_1_TO_1")
1394
- status = "SCRIPT_MISMATCH";
1395
- 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"}`);
1396
- }
1397
- }
1398
- const missingVariant = evidenceOnly
1399
- ? []
1400
- : variantTestPoints.filter((point) => !parameterIds.includes(point));
1401
- const missingAssertion = evidenceOnly
1402
- ? []
1403
- : assertionTestPoints.filter((point) => !assertionBindings.includes(point));
1404
- const missingCrossCutting = evidenceOnly
1405
- ? []
1406
- : crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
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));
1407
1016
  const extraPoints = orderedUnique([...parameterIds, ...assertionBindings, ...crossCuttingBindings]).filter((point) => !testCase.testPoints.includes(point));
1408
1017
  if (testCase.unclassifiedTestPoints.length > 0 || testCase.duplicateBindingTestPoints.length > 0) {
1409
1018
  if (refs.length > 0 && status === "EXACT_1_TO_1")
@@ -1430,16 +1039,6 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1430
1039
  status = "TEST_POINT_BINDING_EXTRA";
1431
1040
  entryFindings.push(`${testCase.caseId} has extra pytest Test Point bindings: ${extraPoints.join(", ")}`);
1432
1041
  }
1433
- if (payloadAssessment.status === "UNAVAILABLE") {
1434
- if (refs.length > 0 && status === "EXACT_1_TO_1")
1435
- status = "PAYLOAD_CONTRACT_UNAVAILABLE";
1436
- entryFindings.push(`${testCase.caseId} has no deterministic Payload Contract declaration`);
1437
- }
1438
- else if (payloadAssessment.status === "UNSAFE") {
1439
- if (refs.length > 0 && status === "EXACT_1_TO_1")
1440
- status = "PAYLOAD_SHAPE_MISMATCH";
1441
- entryFindings.push(`${testCase.caseId} payload contract mismatch: missing=${payloadAssessment.missingPaths.join(", ") || "none"}; unexpected=${payloadAssessment.unexpectedPaths.join(", ") || "none"}; enum=${payloadAssessment.enumMismatches.join(", ") || "none"}`);
1442
- }
1443
1042
  findings.push(...entryFindings);
1444
1043
  entries.push({
1445
1044
  markdownModule: testCase.markdownPath,
@@ -1457,8 +1056,6 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1457
1056
  parameterIds,
1458
1057
  cardinality,
1459
1058
  status,
1460
- payloadAssessment,
1461
- executionSignature: signature,
1462
1059
  findings: entryFindings,
1463
1060
  });
1464
1061
  }
@@ -1485,27 +1082,9 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1485
1082
  parameterIds: symbol.parameterIds,
1486
1083
  cardinality: "0:1",
1487
1084
  status: unknownId ? "EXTRA_PYTEST" : "SYMBOL_MISSING_CASE_ID",
1488
- payloadAssessment: {
1489
- status: "UNAVAILABLE",
1490
- requiredPaths: [],
1491
- allowedPaths: [],
1492
- observedPaths: symbol.payloadPaths,
1493
- missingPaths: [],
1494
- unexpectedPaths: [],
1495
- enumMismatches: [],
1496
- },
1497
1085
  findings: [message],
1498
1086
  });
1499
1087
  }
1500
- const duplicateSignatures = new Map();
1501
- for (const entry of entries.filter((item) => item.caseId && item.executionSignature)) {
1502
- const owners = duplicateSignatures.get(entry.executionSignature) ?? [];
1503
- owners.push(entry.caseId);
1504
- duplicateSignatures.set(entry.executionSignature, owners);
1505
- }
1506
- const duplicateSignatureGroups = [...duplicateSignatures.entries()].filter(([, owners]) => orderedUnique(owners).length > 1);
1507
- for (const [signature, owners] of duplicateSignatureGroups)
1508
- findings.push(`duplicate execution signature ${signature}: ${orderedUnique(owners).join(", ")}`);
1509
1088
  const exactModuleCount = modulePaths.filter((modulePath) => {
1510
1089
  const expected = expectedScript(modulePath);
1511
1090
  const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
@@ -1513,8 +1092,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1513
1092
  }).length;
1514
1093
  const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
1515
1094
  const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
1516
- schemaId: "backend-test-markdown-pytest-correspondence-facts-v3",
1517
- schemaVersion: 3,
1095
+ schemaId: "backend-test-markdown-pytest-correspondence-facts-v2",
1096
+ schemaVersion: 2,
1518
1097
  taskId: input.taskId,
1519
1098
  status: findings.length === 0 ? "PASS" : "FAIL",
1520
1099
  inputFiles: await inputFileFacts(input.workspaceRoot, [
@@ -1539,10 +1118,6 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1539
1118
  crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
1540
1119
  unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
1541
1120
  duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
1542
- payloadSafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "SAFE").length,
1543
- payloadUnsafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNSAFE").length,
1544
- payloadUnavailableCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNAVAILABLE").length,
1545
- duplicateExecutionSignatureCount: duplicateSignatureGroups.length,
1546
1121
  },
1547
1122
  entries,
1548
1123
  findings: orderedUnique(findings),
@@ -1570,16 +1145,12 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1570
1145
  `- Cross-Cutting Test Points: ${facts.summary.crossCuttingTestPointCount}`,
1571
1146
  `- Unclassified Test Points: ${facts.summary.unclassifiedTestPointCount}`,
1572
1147
  `- Duplicate Test Point Bindings: ${facts.summary.duplicateBindingTestPointCount}`,
1573
- `- Payload SAFE: ${facts.summary.payloadSafeCount}`,
1574
- `- Payload UNSAFE: ${facts.summary.payloadUnsafeCount}`,
1575
- `- Payload UNAVAILABLE: ${facts.summary.payloadUnavailableCount}`,
1576
- `- Duplicate Execution Signatures: ${facts.summary.duplicateExecutionSignatureCount}`,
1577
1148
  "",
1578
1149
  "## Correspondence Matrix",
1579
1150
  "",
1580
- "| Markdown Module | Case ID | Variant | Assertion | Cross-Cutting | Mapped | Declared Script | Primary Symbol | Actual Script | Pytest Symbol | Parameter IDs | Payload | Missing Paths | Unexpected Paths | Enum Mismatch | Cardinality | Status |",
1581
- "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
1582
- ...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.payloadAssessment.status} | ${entry.payloadAssessment.missingPaths.join(", ") || "—"} | ${entry.payloadAssessment.unexpectedPaths.join(", ") || "—"} | ${entry.payloadAssessment.enumMismatches.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} |`),
1583
1154
  "",
1584
1155
  "## Findings",
1585
1156
  "",