@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.1
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.
- package/CHANGELOG.md +67 -16
- package/dist/application/task-lifecycle/advance.js +309 -9
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/application/task-lifecycle/plan-transitions.js +13 -21
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/dag-pi-executor.js +18 -3
- package/dist/executors/shell-executor.js +4 -91
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +98 -44
- package/dist/shared/resilient-git.js +133 -0
- package/dist/task/source-prepare/artifact-meta.js +137 -0
- package/dist/task/source-prepare/index.js +2 -0
- package/dist/task/source-prepare/parse-intent.js +58 -10
- package/dist/task/source-prepare/prepare.js +229 -18
- package/dist/task/source-prepare/reference-integrity.js +18 -2
- package/dist/task/source-prepare/semantic-intake.js +404 -0
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +190 -25
- package/dist/worker/console/chat/model-resolver.js +17 -0
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +1007 -188
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +495 -157
- package/dist/worker/console/chat/runtime-context.js +48 -12
- package/dist/worker/console/chat/runtime-selection.js +59 -0
- package/dist/worker/console/chat/session-store.js +39 -0
- package/dist/worker/console/chat/shortcuts.js +1 -0
- package/dist/worker/console/chat/tool-adapter.js +9 -3
- package/dist/worker/console/chat/tools.js +5 -1
- package/dist/worker/console/dag-execution-receipt.js +380 -0
- package/dist/worker/console/interview/grill-me.js +7 -6
- package/dist/worker/console/operator-actions.js +785 -85
- package/dist/worker/console/prd-intake-bridge.js +393 -0
- package/dist/worker/console/recovery-cta.js +35 -6
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +1 -0
- package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
- package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
- package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
- package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
- package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
- package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
- package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
- package/dist/worker/delivery/final-verification.js +13 -5
- package/dist/worker/delivery/package.js +31 -19
- package/dist/worker/delivery/verification-bundle.js +6 -4
- package/dist/worker/observability/read-model.js +3 -0
- package/dist/worker/observe/static/operator-chrome.css +5 -2
- package/dist/worker/observe/static/operator-chrome.js +6 -1
- package/dist/worker/observe/static/styles.css +39 -9
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
- package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
- package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
- package/dist/workflows/dag/backend-test-module-stem.js +0 -5
- package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
- package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
- package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
- package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
- package/dist/workflows/dag/failure-category.js +3 -0
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/init-hybrid.js +49 -55
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/retry-policy.js +44 -11
- package/dist/workflows/dag/runner.js +6 -0
- package/dist/workflows/dag/scheduler.js +49 -1
- package/dist/workflows/dag/types.js +0 -7
- package/dist/workflows/dag/validate.js +16 -2
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/docs/templates/agent-dag.schema.json +4 -4
- package/docs/templates/backend-test-dag.json +29 -32
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/local-jacoco-coverage/SKILL.md +281 -0
- package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
- package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
- package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
- package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
- package/skills/loop-agent/references/command-reference.md +3 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
- package/skills/loop-agent/references/task-workflow.md +4 -0
- package/dist/worker/console/chat/instruction-skills.js +0 -217
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
- 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
|
|
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-
|
|
149
|
-
schemaVersion: z.literal(
|
|
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-
|
|
231
|
-
schemaVersion: z.literal(
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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
|
|
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-
|
|
969
|
-
schemaVersion:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
if (
|
|
1382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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-
|
|
1517
|
-
schemaVersion:
|
|
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 |
|
|
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.
|
|
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
|
"",
|