@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
@@ -149,10 +149,6 @@ export const backendTestCaseManifestSchema = z
149
149
  crossCuttingTestPointCount: z.number().int().min(0),
150
150
  unclassifiedTestPointCount: z.number().int().min(0),
151
151
  duplicateBindingTestPointCount: z.number().int().min(0),
152
- payloadSafeCount: z.number().int().min(0),
153
- payloadUnsafeCount: z.number().int().min(0),
154
- payloadUnavailableCount: z.number().int().min(0),
155
- duplicateExecutionSignatureCount: z.number().int().min(0),
156
152
  }).strict().optional(),
157
153
  artifactRefs: z.object({
158
154
  caseCoverageFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
@@ -471,10 +471,7 @@ export async function validateBackendMarkdownCases(input) {
471
471
  }
472
472
  const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile);
473
473
  const mappedScripts = extractMappedPytestScripts(testCase.body);
474
- if (isEvidenceOnlyMarkdownCaseBody(testCase.body)) {
475
- // Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and keep only assertion/cross-cutting TPs.
476
- }
477
- else if (mappedScripts.length === 0) {
474
+ if (mappedScripts.length === 0) {
478
475
  findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射 (expected ${expectedScript} from module ${path.basename(relativeFile)})`);
479
476
  }
480
477
  else {
@@ -563,24 +560,6 @@ function extractMappedPytestScripts(testCaseBody) {
563
560
  .map((match) => match[1].replaceAll("\\", "/"))
564
561
  .filter((value) => !value.includes("..")));
565
562
  }
566
- function isEvidenceOnlyMarkdownCaseBody(testCaseBody) {
567
- const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
568
- if (!automation.trim())
569
- return false;
570
- const scriptLine = /^\s*[-*+]\s*(?:脚本|script)\s*[::]\s*(.*)$/im.exec(automation)?.[1]?.replaceAll("`", "").trim() ??
571
- "";
572
- const primaryLine = /^\s*[-*+]\s*(?:primary symbol|Primary Symbol|主测试符号)\s*[::]\s*(.*)$/im.exec(automation)?.[1]
573
- ?.replaceAll("`", "")
574
- .trim() ?? "";
575
- const noneLike = /^(?:none|无|n\/a|not-applicable)(?:\s*[((].*[))])?$/i;
576
- const scriptIsNone = noneLike.test(scriptLine) || scriptLine.length === 0;
577
- const primaryIsNone = noneLike.test(primaryLine) || primaryLine.length === 0;
578
- const hasMappedScript = extractMappedPytestScripts(testCaseBody).length > 0;
579
- const hasPrimary = /\btest_[A-Za-z0-9_]+\b/.test(primaryLine);
580
- const hasVariant = /(?:变体测试点|Variant Test Points)\s*[::]\s*(?!\s*(?:无|none)\b)[^\n]*\bTP-[A-Z0-9-]+/i.test(automation);
581
- const hasAssertionOrCross = /(?:场景断言测试点|Assertion Test Points|横切证据测试点|Cross-Cutting Test Points)\s*[::]\s*(?!\s*(?:无|none)\b)[^\n]*\bTP-[A-Z0-9-]+/i.test(automation);
582
- return scriptIsNone && primaryIsNone && !hasMappedScript && !hasPrimary && !hasVariant && hasAssertionOrCross;
583
- }
584
563
  function isSafeBackendPytestScript(script, workspaceRoot) {
585
564
  const absolute = path.resolve(workspaceRoot, script);
586
565
  const relative = path.relative(workspaceRoot, absolute).replaceAll(path.sep, "/");
@@ -642,9 +621,6 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
642
621
  for (const testCase of splitCases(content)) {
643
622
  markdownIds.add(testCase.id);
644
623
  const scripts = extractMappedPytestScripts(testCase.body);
645
- if (isEvidenceOnlyMarkdownCaseBody(testCase.body)) {
646
- continue;
647
- }
648
624
  if (scripts.length === 0) {
649
625
  findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射`);
650
626
  continue;
@@ -1,7 +1,5 @@
1
1
  import path from "node:path";
2
2
  const PRIORITY_ONLY_MODULE_STEMS = new Set(["p0", "p1", "p2"]);
3
- const OPAQUE_HASH_MODULE_STEM = /^[a-f][a-f0-9]{6,63}$/;
4
- export const BACKEND_TEST_MAX_MODULE_COUNT = 8;
5
3
  export function normalizeBackendTestModuleStemCandidate(raw) {
6
4
  return path.basename(raw)
7
5
  .replace(/\.(?:md|py)$/i, "")
@@ -13,9 +11,6 @@ export function normalizeBackendTestModuleStemCandidate(raw) {
13
11
  export function isPriorityOnlyBackendTestModuleStem(raw) {
14
12
  return PRIORITY_ONLY_MODULE_STEMS.has(normalizeBackendTestModuleStemCandidate(raw));
15
13
  }
16
- export function isOpaqueHashBackendTestModuleStem(raw) {
17
- return OPAQUE_HASH_MODULE_STEM.test(normalizeBackendTestModuleStemCandidate(raw));
18
- }
19
14
  export function isPriorityOnlyBackendPytestScript(script) {
20
15
  const basename = path.posix.basename(script.replaceAll("\\", "/"));
21
16
  const match = /^test_(.+)\.py$/i.exec(basename);
@@ -5,15 +5,6 @@ import { z } from "zod";
5
5
  import { priorityOnlyBackendPytestScripts } from "./backend-test-module-stem.js";
6
6
  const SHA256 = /^[a-f0-9]{64}$/;
7
7
  const MAX_DIAGNOSTIC_CHARS = 12_000;
8
- /** Upper bound for assertBackendTestExecutionReadinessFresh Error.message (not collection excerpts). */
9
- export const MAX_READINESS_ERROR_CHARS = 2_048;
10
- const READINESS_EXCLUSION_CATEGORIES = [
11
- "correspondence-missing",
12
- "correspondence-status",
13
- "payload-contract-status",
14
- "scenario-param-field",
15
- "other",
16
- ];
17
8
  export const backendPytestCollectionFindingSchema = z.object({
18
9
  kind: z.string().min(1),
19
10
  classification: z.literal("test-asset-defect"),
@@ -36,8 +27,6 @@ export const backendPytestCollectionFactsSchema = z.object({
36
27
  missingMappedScripts: z.array(z.string()),
37
28
  assetFiles: z.array(z.string()),
38
29
  inputHashes: z.record(z.string(), z.string().regex(SHA256)),
39
- hazardOccurrenceCounts: z.record(z.string(), z.record(z.string(), z.number().int().min(0))),
40
- hazardOccurrences: z.record(z.string(), z.record(z.string(), z.array(z.string()))).optional(),
41
30
  pytestExitCode: z.number().int().nullable(),
42
31
  collectedItemCount: z.number().int().min(0),
43
32
  collectedItemIds: z.array(z.string()),
@@ -60,14 +49,8 @@ export const backendPytestCollectionFactsSchema = z.object({
60
49
  context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
61
50
  }
62
51
  });
63
- const backendTestExcludedItemSchema = z.object({
64
- itemId: z.string().min(1),
65
- caseId: z.string().optional(),
66
- symbol: z.string().optional(),
67
- reasons: z.array(z.string().min(1)).min(1),
68
- }).strict();
69
52
  export const backendTestExecutionReadinessSchema = z.object({
70
- schemaId: z.literal("backend-test-execution-readiness-v2"),
53
+ schemaId: z.literal("backend-test-execution-readiness-v1"),
71
54
  status: z.enum(["PASS", "PARTIAL", "BLOCKED"]),
72
55
  collectionStatus: z.literal("PASS"),
73
56
  fixtureResolutionStatus: z.literal("PASS"),
@@ -78,94 +61,9 @@ export const backendTestExecutionReadinessSchema = z.object({
78
61
  }).strict(),
79
62
  mappedScripts: z.array(z.string()).min(1),
80
63
  collectedItemIds: z.array(z.string()),
81
- eligibleItemIds: z.array(z.string()),
82
- excludedItems: z.array(backendTestExcludedItemSchema),
83
64
  fixtureIssues: z.array(z.string()),
84
65
  assetHashes: z.record(z.string(), z.string().regex(SHA256)),
85
- eligibilityInputHashes: z.record(z.string(), z.string().regex(SHA256)),
86
66
  }).strict();
87
- const REPAIR_HAZARD_PATTERNS = [
88
- { label: "skip/xfail", source: "pytest\\.mark\\.(?:skip|skipif|xfail)\\b|pytest\\.(?:skip|xfail)\\s*\\(" },
89
- { label: "sys.path/PYTHONPATH mutation", source: "sys\\.path\\s*\\.|PYTHONPATH" },
90
- { label: "ImportError fallback", source: "except\\s+ImportError\\b" },
91
- { label: "broad exception swallowing", source: "except\\s+(?:Exception|BaseException)\\b[\\s\\S]{0,160}?\\bpass\\b" },
92
- ];
93
- function normalizeSourceNewlines(source) {
94
- return source.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
95
- }
96
- /**
97
- * Normalize a hazard occurrence identity by stripping trailing Python `#` comments
98
- * while preserving `#` characters inside string literals. Fail closed: if quote
99
- * state is ambiguous, keep the original trimmed line rather than over-stripping.
100
- */
101
- function normalizeHazardOccurrenceIdentity(line) {
102
- const trimmed = line.trimEnd().trim();
103
- if (!trimmed.includes("#"))
104
- return trimmed;
105
- let inSingle = false;
106
- let inDouble = false;
107
- let escaped = false;
108
- for (let i = 0; i < trimmed.length; i += 1) {
109
- const ch = trimmed[i];
110
- if (escaped) {
111
- escaped = false;
112
- continue;
113
- }
114
- if ((inSingle || inDouble) && ch === "\\") {
115
- escaped = true;
116
- continue;
117
- }
118
- if (!inDouble && ch === "'") {
119
- inSingle = !inSingle;
120
- continue;
121
- }
122
- if (!inSingle && ch === '"') {
123
- inDouble = !inDouble;
124
- continue;
125
- }
126
- if (!inSingle && !inDouble && ch === "#") {
127
- return trimmed.slice(0, i).trimEnd();
128
- }
129
- }
130
- // Unclosed quote or no out-of-string `#`: do not invent a comment boundary.
131
- return trimmed;
132
- }
133
- function listPatternOccurrences(source, patternSource, flags = "") {
134
- const normalized = normalizeSourceNewlines(source);
135
- const pattern = new RegExp(patternSource, flags.includes("g") ? flags : `${flags}g`);
136
- const identities = [];
137
- for (const match of normalized.matchAll(pattern)) {
138
- const index = match.index ?? 0;
139
- const lineStart = normalized.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
140
- const lineEnd = normalized.indexOf("\n", index);
141
- const line = normalized.slice(lineStart, lineEnd === -1 ? normalized.length : lineEnd).trimEnd();
142
- identities.push(normalizeHazardOccurrenceIdentity(line));
143
- }
144
- return identities;
145
- }
146
- function scanRepairHazardOccurrenceLists(source) {
147
- const lists = {};
148
- for (const rule of REPAIR_HAZARD_PATTERNS) {
149
- lists[rule.label] = listPatternOccurrences(source, rule.source, rule.flags);
150
- }
151
- return lists;
152
- }
153
- function multisetCounts(identities) {
154
- const counts = new Map();
155
- for (const identity of identities) {
156
- counts.set(identity, (counts.get(identity) ?? 0) + 1);
157
- }
158
- return counts;
159
- }
160
- function hasMultisetIncrement(initialIdentities, finalIdentities) {
161
- const initial = multisetCounts(initialIdentities);
162
- const final = multisetCounts(finalIdentities);
163
- for (const [identity, finalCount] of final) {
164
- if (finalCount > (initial.get(identity) ?? 0))
165
- return true;
166
- }
167
- return false;
168
- }
169
67
  function repoRef(workspaceRoot, absolutePath) {
170
68
  return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
171
69
  }
@@ -214,17 +112,9 @@ export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScri
214
112
  const pythonFiles = await walkPythonFiles(path.resolve(workspaceRoot, "testcase"));
215
113
  const assetFiles = [...new Set(pythonFiles.map((file) => repoRef(workspaceRoot, file)))].sort();
216
114
  const inputHashes = {};
217
- const hazardOccurrenceCounts = {};
218
- const hazardOccurrences = {};
219
- for (const file of assetFiles) {
220
- const bytes = await readFile(path.resolve(workspaceRoot, file));
221
- const source = bytes.toString("utf8");
222
- inputHashes[file] = sha256(bytes);
223
- const lists = scanRepairHazardOccurrenceLists(source);
224
- hazardOccurrences[file] = lists;
225
- hazardOccurrenceCounts[file] = Object.fromEntries(Object.entries(lists).map(([label, identities]) => [label, identities.length]));
226
- }
227
- return { mappedScripts: normalizedScripts, assetFiles, inputHashes, hazardOccurrenceCounts, hazardOccurrences };
115
+ for (const file of assetFiles)
116
+ inputHashes[file] = sha256(await readFile(path.resolve(workspaceRoot, file)));
117
+ return { mappedScripts: normalizedScripts, assetFiles, inputHashes };
228
118
  }
229
119
  function bounded(value) {
230
120
  const normalized = value.replaceAll(/(Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+/gi, "$1 [REDACTED]");
@@ -248,10 +138,6 @@ function classifyFixtureResolutionFailure(output) {
248
138
  const normalized = output.replaceAll("\\", "/");
249
139
  const paths = testcasePythonPaths(normalized);
250
140
  const generatedProviderPaths = paths.filter((item) => /testcase\/(?:helpers|factories)\//.test(item));
251
- const generatedTestModulePaths = paths.filter((item) => /testcase\/(?:[A-Za-z0-9_./-]+\/)?test_[A-Za-z0-9_.-]+\.py$/i.test(item));
252
- const onlyGeneratedTestcasePaths = paths.length > 0 && paths.every((item) => item.startsWith("testcase/"));
253
- const mentionsProduction = /(?:apps|src|server)\/[A-Za-z0-9_./-]+\.py/i.test(normalized) ||
254
- /No module named ['"](?!testcase(?:\.|['"]))/i.test(normalized);
255
141
  if (/fixture ['"][^'"]+['"] not found/i.test(normalized) && generatedProviderPaths.length > 0) {
256
142
  return {
257
143
  status: "REPAIRABLE",
@@ -260,33 +146,6 @@ function classifyFixtureResolutionFailure(output) {
260
146
  repairPaths: generatedProviderPaths,
261
147
  };
262
148
  }
263
- // ScopeMismatch and other setup failures fully attributed to generated test modules are
264
- // writer-local and may be fixed by one bounded repair (align scopes / define fixtures).
265
- if (!mentionsProduction &&
266
- onlyGeneratedTestcasePaths &&
267
- generatedTestModulePaths.length > 0 &&
268
- /ScopeMismatch|scope mismatch|function scoped fixture|module scoped request object|module scoped fixture|ERROR at setup of/i.test(normalized)) {
269
- const isScope = /ScopeMismatch|scope mismatch|function scoped fixture|module scoped request object|module scoped fixture/i.test(normalized);
270
- return {
271
- status: "REPAIRABLE",
272
- kind: isScope ? "generated-fixture-scope-mismatch" : "generated-fixture-setup-defect",
273
- detail: isScope
274
- ? "generated pytest fixture scopes are inconsistent inside mapped test modules"
275
- : "generated pytest fixture setup failed inside mapped test modules",
276
- repairPaths: [...new Set([...generatedTestModulePaths, ...generatedProviderPaths])].sort(),
277
- };
278
- }
279
- if (!mentionsProduction &&
280
- onlyGeneratedTestcasePaths &&
281
- generatedTestModulePaths.length > 0 &&
282
- /fixture ['"][^'"]+['"] not found/i.test(normalized)) {
283
- return {
284
- status: "REPAIRABLE",
285
- kind: "missing-generated-fixture",
286
- detail: "mapped pytest module requests a fixture not defined in generated testcase assets",
287
- repairPaths: [...new Set([...generatedTestModulePaths, ...generatedProviderPaths])].sort(),
288
- };
289
- }
290
149
  return {
291
150
  status: "BLOCKED",
292
151
  kind: "unresolved-fixture-dependency",
@@ -340,8 +199,6 @@ export function assessBackendPytestCollection(input) {
340
199
  missingMappedScripts: [],
341
200
  assetFiles: input.inventory.assetFiles,
342
201
  inputHashes: input.inventory.inputHashes,
343
- hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
344
- hazardOccurrences: input.inventory.hazardOccurrences,
345
202
  pytestExitCode: input.exitCode,
346
203
  collectedItemCount: items.length,
347
204
  collectedItemIds: items,
@@ -370,8 +227,6 @@ export function assessBackendPytestCollection(input) {
370
227
  missingMappedScripts: [],
371
228
  assetFiles: input.inventory.assetFiles,
372
229
  inputHashes: input.inventory.inputHashes,
373
- hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
374
- hazardOccurrences: input.inventory.hazardOccurrences,
375
230
  pytestExitCode: input.exitCode,
376
231
  collectedItemCount: items.length,
377
232
  collectedItemIds: items,
@@ -404,8 +259,6 @@ export function assessBackendPytestCollection(input) {
404
259
  missingMappedScripts: [],
405
260
  assetFiles: input.inventory.assetFiles,
406
261
  inputHashes: input.inventory.inputHashes,
407
- hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
408
- hazardOccurrences: input.inventory.hazardOccurrences,
409
262
  pytestExitCode: input.exitCode,
410
263
  collectedItemCount: items.length,
411
264
  collectedItemIds: items,
@@ -441,8 +294,6 @@ export function assessPriorityOnlyBackendPytestModules(input) {
441
294
  missingMappedScripts: [],
442
295
  assetFiles: input.inventory.assetFiles,
443
296
  inputHashes: input.inventory.inputHashes,
444
- hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
445
- hazardOccurrences: input.inventory.hazardOccurrences,
446
297
  pytestExitCode: null,
447
298
  collectedItemCount: 0,
448
299
  collectedItemIds: [],
@@ -475,8 +326,6 @@ export function assessMissingBackendPytestScripts(input) {
475
326
  missingMappedScripts: [...input.missingMappedScripts],
476
327
  assetFiles: [...input.assetFiles],
477
328
  inputHashes: input.inputHashes,
478
- hazardOccurrenceCounts: input.hazardOccurrenceCounts ?? Object.fromEntries(input.assetFiles.map((file) => [file, {}])),
479
- hazardOccurrences: input.hazardOccurrences ?? Object.fromEntries(input.assetFiles.map((file) => [file, {}])),
480
329
  pytestExitCode: null,
481
330
  collectedItemCount: 0,
482
331
  collectedItemIds: [],
@@ -566,21 +415,17 @@ async function assertBackendPytestRepairSafety(workspaceRoot, initial, final) {
566
415
  if (JSON.stringify(initial.mappedScripts) !== JSON.stringify(final.mappedScripts)) {
567
416
  throw new Error("backend pytest repair changed mapped script scope");
568
417
  }
418
+ const forbidden = [
419
+ [/pytest\.mark\.(?:skip|skipif|xfail)\b|pytest\.(?:skip|xfail)\s*\(/, "skip/xfail"],
420
+ [/sys\.path\s*\.|PYTHONPATH/, "sys.path/PYTHONPATH mutation"],
421
+ [/except\s+ImportError\b/, "ImportError fallback"],
422
+ [/except\s+(?:Exception|BaseException)\b[\s\S]{0,160}?\bpass\b/, "broad exception swallowing"],
423
+ ];
569
424
  for (const file of final.assetFiles) {
570
425
  const source = await readFile(path.resolve(workspaceRoot, file), "utf8");
571
- const liveHash = sha256(source);
572
- if (final.inputHashes[file] && final.inputHashes[file] !== liveHash) {
573
- throw new Error(`backend pytest collection hash drift: ${file}`);
574
- }
575
- if (initial.inputHashes[file] === final.inputHashes[file])
576
- continue;
577
- const finalLists = scanRepairHazardOccurrenceLists(source);
578
- for (const rule of REPAIR_HAZARD_PATTERNS) {
579
- const baseline = initial.hazardOccurrences?.[file]?.[rule.label] ?? [];
580
- const finalIdentities = finalLists[rule.label] ?? [];
581
- if (hasMultisetIncrement(baseline, finalIdentities)) {
582
- throw new Error(`backend pytest repair safety blocked ${rule.label}: ${file}`);
583
- }
426
+ for (const [pattern, label] of forbidden) {
427
+ if (pattern.test(source))
428
+ throw new Error(`backend pytest repair safety blocked ${label}: ${file}`);
584
429
  }
585
430
  }
586
431
  }
@@ -594,65 +439,23 @@ function assertSameInventory(expected, actual) {
594
439
  throw new Error(`backend pytest collection hash drift: ${file}`);
595
440
  }
596
441
  }
597
- export function buildBackendTestItemEligibility(collectedItemIds, input) {
598
- const eligibleItemIds = [];
599
- const excludedItems = [];
600
- for (const itemId of collectedItemIds) {
601
- const segments = itemId.split("::");
602
- const symbolSegment = [...segments].reverse().find((segment) => /^test_/.test(segment));
603
- const symbol = symbolSegment?.replace(/\[.*$/, "");
604
- const parameterTokens = orderedUnique((itemId.match(/TP-[A-Z0-9-]+/g) ?? []));
605
- const mapping = input.correspondenceEntries.find((entry) => symbol && (entry.declaredPrimarySymbol === symbol || entry.pytestSymbols.includes(symbol)));
606
- const reasons = [];
607
- if (!mapping)
608
- reasons.push("no exact Markdown Case/primary-symbol correspondence");
609
- if (mapping && mapping.status !== "EXACT_1_TO_1")
610
- reasons.push(`correspondence status is ${mapping.status}`);
611
- if (mapping && mapping.payloadAssessment.status !== "SAFE")
612
- reasons.push(`payload contract status is ${mapping.payloadAssessment.status}`);
613
- const scenarioEntries = mapping?.caseId ? input.scenarioEntries.filter((entry) => entry.caseId === mapping.caseId) : [];
614
- const relevantScenarioEntries = parameterTokens.length > 0
615
- ? scenarioEntries.filter((entry) => parameterTokens.includes(entry.tpId))
616
- : scenarioEntries;
617
- const payloadPaths = orderedUnique([
618
- ...(mapping?.payloadAssessment.requiredPaths ?? []),
619
- ...(mapping?.payloadAssessment.allowedPaths ?? []),
620
- ...(mapping?.payloadAssessment.observedPaths ?? []),
621
- ]).map((item) => item.toLowerCase());
622
- for (const entry of relevantScenarioEntries) {
623
- // Field names heuristically inferred only from a TP token (for example
624
- // TP-HEALTH-HTTP-200 -> hEALTH) are not execution-safety evidence. Exclude
625
- // only when the field is also present in the deterministic payload contract
626
- // or observed Python payload shape.
627
- const normalizedField = entry.field?.toLowerCase();
628
- const fieldIsPayloadBound = Boolean(normalizedField && payloadPaths.some((item) => item === normalizedField || item.endsWith(`.${normalizedField}`)));
629
- if (fieldIsPayloadBound && entry.status !== "MATCH")
630
- reasons.push(`scenario-param ${entry.tpId} field ${entry.field} is ${entry.status}`);
631
- }
632
- if (reasons.length === 0)
633
- eligibleItemIds.push(itemId);
634
- else
635
- excludedItems.push({ itemId, ...(mapping?.caseId ? { caseId: mapping.caseId } : {}), ...(symbol ? { symbol } : {}), reasons: orderedUnique(reasons) });
636
- }
637
- return { eligibleItemIds, excludedItems };
638
- }
639
- function orderedUnique(values) {
640
- return [...new Set(values)];
641
- }
642
442
  export async function materializeBackendTestExecutionReadiness(input) {
643
443
  if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
644
444
  throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
645
445
  }
646
446
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
447
+ const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
448
+ if (priorityOnly.length > 0) {
449
+ throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
450
+ }
647
451
  assertSameInventory(input.effective, current);
648
- const eligibility = input.eligibility ?? { eligibleItemIds: input.effective.collectedItemIds, excludedItems: [] };
649
- const status = eligibility.eligibleItemIds.length === 0
452
+ const status = input.scenarioParamStatus === "FAIL"
650
453
  ? "BLOCKED"
651
- : input.scenarioParamStatus === "PASS" && eligibility.excludedItems.length === 0
454
+ : input.scenarioParamStatus === "PASS"
652
455
  ? "PASS"
653
456
  : "PARTIAL";
654
457
  const readiness = backendTestExecutionReadinessSchema.parse({
655
- schemaId: "backend-test-execution-readiness-v2",
458
+ schemaId: "backend-test-execution-readiness-v1",
656
459
  status,
657
460
  collectionStatus: "PASS",
658
461
  fixtureResolutionStatus: "PASS",
@@ -663,11 +466,8 @@ export async function materializeBackendTestExecutionReadiness(input) {
663
466
  },
664
467
  mappedScripts: input.effective.mappedScripts,
665
468
  collectedItemIds: input.effective.collectedItemIds,
666
- eligibleItemIds: eligibility.eligibleItemIds,
667
- excludedItems: eligibility.excludedItems,
668
469
  fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
669
470
  assetHashes: input.effective.inputHashes,
670
- eligibilityInputHashes: input.eligibilityInputHashes ?? {},
671
471
  });
672
472
  const contractsDir = path.join(input.runDir, "contracts");
673
473
  await mkdir(contractsDir, { recursive: true });
@@ -677,145 +477,27 @@ export async function materializeBackendTestExecutionReadiness(input) {
677
477
  export async function readBackendTestExecutionReadiness(filePath) {
678
478
  return backendTestExecutionReadinessSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
679
479
  }
680
- function formatReadinessCounts(readiness) {
681
- return `mapped=${readiness.mappedScripts.length} collected=${readiness.collectedItemIds.length} eligible=${readiness.eligibleItemIds.length} excluded=${readiness.excludedItems.length}`;
682
- }
683
- export function classifyExclusionReason(reason) {
684
- if (reason === "no exact Markdown Case/primary-symbol correspondence")
685
- return "correspondence-missing";
686
- if (/^correspondence status is\b/.test(reason))
687
- return "correspondence-status";
688
- if (/^payload contract status is\b/.test(reason))
689
- return "payload-contract-status";
690
- if (/^scenario-param\b.*\bfield\b/.test(reason))
691
- return "scenario-param-field";
692
- return "other";
693
- }
694
- /** Fixed-category tally for readiness Error messages — never echoes raw reason text. */
695
- function tallyExclusionReasonCategories(excludedItems) {
696
- const counts = new Map();
697
- for (const item of excludedItems) {
698
- for (const reason of item.reasons) {
699
- const category = classifyExclusionReason(reason);
700
- counts.set(category, (counts.get(category) ?? 0) + 1);
701
- }
702
- }
703
- const parts = READINESS_EXCLUSION_CATEGORIES
704
- .filter((category) => (counts.get(category) ?? 0) > 0)
705
- .map((category) => `${category}=${counts.get(category)}`);
706
- return parts.length > 0 ? parts.join("; ") : "none";
707
- }
708
- const READINESS_ERROR_TRUNCATION_SUFFIX = "...[truncated]";
709
- export function boundReadinessErrorMessage(message) {
710
- if (message.length <= MAX_READINESS_ERROR_CHARS)
711
- return message;
712
- // Truncate to leave room for the suffix so the final message never exceeds
713
- // MAX_READINESS_ERROR_CHARS. Never append beyond the declared limit.
714
- const limit = MAX_READINESS_ERROR_CHARS - READINESS_ERROR_TRUNCATION_SUFFIX.length;
715
- return `${message.slice(0, Math.max(0, limit))}${READINESS_ERROR_TRUNCATION_SUFFIX}`;
716
- }
717
- function throwReadinessError(message) {
718
- throw new Error(boundReadinessErrorMessage(message));
719
- }
720
- function firstInventoryDeltaPath(expectedHashes, actualHashes) {
721
- const expectedFiles = Object.keys(expectedHashes).sort();
722
- const actualFiles = Object.keys(actualHashes).sort();
723
- const actualSet = new Set(actualFiles);
724
- const expectedSet = new Set(expectedFiles);
725
- const onlyExpected = expectedFiles.find((file) => !actualSet.has(file));
726
- if (onlyExpected)
727
- return onlyExpected;
728
- return actualFiles.find((file) => !expectedSet.has(file));
729
- }
730
- async function firstUnreadableAssetPath(workspaceRoot, readiness) {
731
- const candidatePaths = [...new Set([
732
- ...Object.keys(readiness.assetHashes),
733
- ...readiness.mappedScripts,
734
- ])].sort();
735
- for (const relativePath of candidatePaths) {
736
- try {
737
- await readFile(path.resolve(workspaceRoot, relativePath));
738
- }
739
- catch {
740
- return relativePath;
741
- }
742
- }
743
- return undefined;
744
- }
745
480
  export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
746
481
  if (!["PASS", "PARTIAL"].includes(readiness.status)) {
747
- const counts = formatReadinessCounts(readiness);
748
- const reasonTally = readiness.eligibleItemIds.length === 0 && readiness.excludedItems.length > 0
749
- ? `; exclusionReasons: ${tallyExclusionReasonCategories(readiness.excludedItems)}`
750
- : "";
751
- throwReadinessError(`backend pytest execution readiness is ${readiness.status}: ${counts}${reasonTally}`);
752
- }
753
- if (readiness.eligibleItemIds.length === 0) {
754
- throwReadinessError(`backend pytest execution readiness authorizes zero eligible items: ${tallyExclusionReasonCategories(readiness.excludedItems)}`);
755
- }
756
- const missingEligibleCount = readiness.eligibleItemIds.filter((itemId) => !readiness.collectedItemIds.includes(itemId)).length;
757
- if (missingEligibleCount > 0) {
758
- // Do not replay raw pytest item IDs: they may contain TP-*, field, payload,
759
- // secret-like or parameter markers. Report only the fixed category + count.
760
- throwReadinessError(`backend pytest readiness has ${missingEligibleCount} eligible item(s) absent from collected items: ${formatReadinessCounts(readiness)}`);
761
- }
762
- const unreadableAsset = await firstUnreadableAssetPath(workspaceRoot, readiness);
763
- if (unreadableAsset) {
764
- throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${unreadableAsset}`);
765
- }
766
- let current;
767
- try {
768
- current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
769
- }
770
- catch {
771
- const fallbackPath = [...readiness.mappedScripts].sort()[0] ?? Object.keys(readiness.assetHashes).sort()[0] ?? "unknown";
772
- throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${fallbackPath}`);
482
+ throw new Error(`backend pytest execution readiness is ${readiness.status}`);
773
483
  }
484
+ const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
774
485
  const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
775
486
  if (priorityOnly.length > 0) {
776
- throwReadinessError(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
777
- }
778
- const expectedHashes = readiness.assetHashes;
779
- const inventoryDelta = firstInventoryDeltaPath(expectedHashes, current.inputHashes);
780
- if (inventoryDelta) {
781
- throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${inventoryDelta}`);
782
- }
783
- for (const file of Object.keys(expectedHashes).sort()) {
784
- if (expectedHashes[file] !== current.inputHashes[file]) {
785
- throwReadinessError(`backend pytest collection hash drift: ${file}`);
786
- }
787
- }
788
- for (const relativePath of Object.keys(readiness.eligibilityInputHashes).sort()) {
789
- const expectedHash = readiness.eligibilityInputHashes[relativePath];
790
- const absolute = path.resolve(workspaceRoot, relativePath);
791
- let actualHash;
792
- try {
793
- actualHash = sha256(await readFile(absolute));
794
- }
795
- catch {
796
- throwReadinessError(`backend pytest eligibility input hash drift: firstPath=${relativePath}`);
797
- }
798
- if (actualHash !== expectedHash) {
799
- throwReadinessError(`backend pytest eligibility input hash drift: ${relativePath}`);
800
- }
487
+ throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
801
488
  }
489
+ assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
802
490
  }
803
491
  export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
804
492
  if (effective.phase !== "effective" || effective.status !== "PASS") {
805
493
  throw new Error("backend pytest execution requires effective collection PASS facts");
806
494
  }
807
495
  const current = await buildBackendPytestAssetInventory(workspaceRoot, effective.mappedScripts);
808
- const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
809
- if (priorityOnly.length > 0)
810
- throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
811
496
  assertSameInventory(effective, current);
812
497
  }
813
498
  export async function materializeEffectiveBackendPytestCollection(input) {
814
499
  if (input.initial.status === "PASS") {
815
500
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.initial.mappedScripts);
816
- const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
817
- if (priorityOnly.length > 0)
818
- throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
819
501
  assertSameInventory(input.initial, current);
820
502
  return backendPytestCollectionFactsSchema.parse({
821
503
  ...input.initial,
@@ -831,9 +513,6 @@ export async function materializeEffectiveBackendPytestCollection(input) {
831
513
  throw new Error("backend pytest collection still fails after bounded repair");
832
514
  await assertBackendPytestRepairSafety(input.workspaceRoot, input.initial, input.final);
833
515
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.final.mappedScripts);
834
- const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
835
- if (priorityOnly.length > 0)
836
- throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
837
516
  assertSameInventory(input.final, current);
838
517
  return backendPytestCollectionFactsSchema.parse({
839
518
  ...input.final,