@tea-agent/loop-agent 0.33.6 → 0.33.7-beta.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 (69) hide show
  1. package/CHANGELOG.md +23 -23
  2. package/dist/application/task-lifecycle/advance.js +4 -254
  3. package/dist/application/task-lifecycle/gates.js +0 -50
  4. package/dist/application/task-lifecycle/observe.js +2 -11
  5. package/dist/commands/init-upgrade.js +1 -32
  6. package/dist/commands/init.js +3 -94
  7. package/dist/executors/shell-executor.js +91 -4
  8. package/dist/executors/shell-write-guard.js +8 -26
  9. package/dist/shared/operator/capabilities.js +42 -72
  10. package/dist/task/source-prepare/index.js +0 -2
  11. package/dist/task/source-prepare/parse-intent.js +10 -58
  12. package/dist/task/source-prepare/prepare.js +16 -180
  13. package/dist/task/source-prepare/reference-integrity.js +2 -18
  14. package/dist/worker/console/app-data.js +0 -2
  15. package/dist/worker/console/chat/chat-event-store.js +25 -190
  16. package/dist/worker/console/chat/instruction-skills.js +217 -0
  17. package/dist/worker/console/chat/pi-console-config.js +32 -250
  18. package/dist/worker/console/chat/pi-runtime.js +71 -625
  19. package/dist/worker/console/chat/resource-loader.js +4 -5
  20. package/dist/worker/console/chat/routes.js +146 -324
  21. package/dist/worker/console/chat/runtime-context.js +12 -48
  22. package/dist/worker/console/chat/runtime-selection.js +0 -59
  23. package/dist/worker/console/chat/shortcuts.js +0 -1
  24. package/dist/worker/console/chat/tool-adapter.js +3 -9
  25. package/dist/worker/console/chat/tools.js +1 -5
  26. package/dist/worker/console/operator-actions.js +68 -559
  27. package/dist/worker/console/server.js +15 -8
  28. package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
  29. package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
  30. package/dist/worker/console/static/index.html +2 -2
  31. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
  32. package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
  33. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
  34. package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
  35. package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
  36. package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
  37. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
  38. package/dist/worker/delivery/final-verification.js +5 -13
  39. package/dist/worker/delivery/package.js +19 -31
  40. package/dist/worker/delivery/verification-bundle.js +4 -6
  41. package/dist/worker/observe/static/operator-chrome.css +2 -5
  42. package/dist/worker/observe/static/operator-chrome.js +1 -6
  43. package/dist/worker/observe/static/styles.css +9 -39
  44. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
  45. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  46. package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
  47. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  48. package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
  49. package/dist/workflows/dag/backend-test-scenario-param.js +269 -82
  50. package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
  51. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  52. package/dist/workflows/dag/frontend-worktree-diff.js +27 -12
  53. package/dist/workflows/dag/init-hybrid.js +46 -34
  54. package/dist/workflows/dag/types.js +7 -0
  55. package/dist/workflows/dag/workspace-checkpoint.js +27 -8
  56. package/docs/templates/backend-test-dag.json +32 -29
  57. package/harness.json +1 -1
  58. package/package.json +1 -1
  59. package/skills/loop-agent/references/command-reference.md +1 -3
  60. package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
  61. package/skills/loop-agent/references/task-workflow.md +0 -4
  62. package/dist/shared/resilient-git.js +0 -133
  63. package/dist/task/source-prepare/artifact-meta.js +0 -137
  64. package/dist/task/source-prepare/semantic-intake.js +0 -404
  65. package/dist/worker/console/dag-execution-receipt.js +0 -380
  66. package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
  67. package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
  68. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
  69. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +0 -196
@@ -149,6 +149,10 @@ 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),
152
156
  }).strict().optional(),
153
157
  artifactRefs: z.object({
154
158
  caseCoverageFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
@@ -471,7 +471,10 @@ export async function validateBackendMarkdownCases(input) {
471
471
  }
472
472
  const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile);
473
473
  const mappedScripts = extractMappedPytestScripts(testCase.body);
474
- if (mappedScripts.length === 0) {
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) {
475
478
  findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射 (expected ${expectedScript} from module ${path.basename(relativeFile)})`);
476
479
  }
477
480
  else {
@@ -560,6 +563,24 @@ function extractMappedPytestScripts(testCaseBody) {
560
563
  .map((match) => match[1].replaceAll("\\", "/"))
561
564
  .filter((value) => !value.includes("..")));
562
565
  }
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
+ }
563
584
  function isSafeBackendPytestScript(script, workspaceRoot) {
564
585
  const absolute = path.resolve(workspaceRoot, script);
565
586
  const relative = path.relative(workspaceRoot, absolute).replaceAll(path.sep, "/");
@@ -621,6 +642,9 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
621
642
  for (const testCase of splitCases(content)) {
622
643
  markdownIds.add(testCase.id);
623
644
  const scripts = extractMappedPytestScripts(testCase.body);
645
+ if (isEvidenceOnlyMarkdownCaseBody(testCase.body)) {
646
+ continue;
647
+ }
624
648
  if (scripts.length === 0) {
625
649
  findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射`);
626
650
  continue;
@@ -1,5 +1,7 @@
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;
3
5
  export function normalizeBackendTestModuleStemCandidate(raw) {
4
6
  return path.basename(raw)
5
7
  .replace(/\.(?:md|py)$/i, "")
@@ -11,6 +13,9 @@ export function normalizeBackendTestModuleStemCandidate(raw) {
11
13
  export function isPriorityOnlyBackendTestModuleStem(raw) {
12
14
  return PRIORITY_ONLY_MODULE_STEMS.has(normalizeBackendTestModuleStemCandidate(raw));
13
15
  }
16
+ export function isOpaqueHashBackendTestModuleStem(raw) {
17
+ return OPAQUE_HASH_MODULE_STEM.test(normalizeBackendTestModuleStemCandidate(raw));
18
+ }
14
19
  export function isPriorityOnlyBackendPytestScript(script) {
15
20
  const basename = path.posix.basename(script.replaceAll("\\", "/"));
16
21
  const match = /^test_(.+)\.py$/i.exec(basename);
@@ -5,6 +5,15 @@ 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
+ ];
8
17
  export const backendPytestCollectionFindingSchema = z.object({
9
18
  kind: z.string().min(1),
10
19
  classification: z.literal("test-asset-defect"),
@@ -27,6 +36,8 @@ export const backendPytestCollectionFactsSchema = z.object({
27
36
  missingMappedScripts: z.array(z.string()),
28
37
  assetFiles: z.array(z.string()),
29
38
  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(),
30
41
  pytestExitCode: z.number().int().nullable(),
31
42
  collectedItemCount: z.number().int().min(0),
32
43
  collectedItemIds: z.array(z.string()),
@@ -49,8 +60,14 @@ export const backendPytestCollectionFactsSchema = z.object({
49
60
  context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
50
61
  }
51
62
  });
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();
52
69
  export const backendTestExecutionReadinessSchema = z.object({
53
- schemaId: z.literal("backend-test-execution-readiness-v1"),
70
+ schemaId: z.literal("backend-test-execution-readiness-v2"),
54
71
  status: z.enum(["PASS", "PARTIAL", "BLOCKED"]),
55
72
  collectionStatus: z.literal("PASS"),
56
73
  fixtureResolutionStatus: z.literal("PASS"),
@@ -61,9 +78,94 @@ export const backendTestExecutionReadinessSchema = z.object({
61
78
  }).strict(),
62
79
  mappedScripts: z.array(z.string()).min(1),
63
80
  collectedItemIds: z.array(z.string()),
81
+ eligibleItemIds: z.array(z.string()),
82
+ excludedItems: z.array(backendTestExcludedItemSchema),
64
83
  fixtureIssues: z.array(z.string()),
65
84
  assetHashes: z.record(z.string(), z.string().regex(SHA256)),
85
+ eligibilityInputHashes: z.record(z.string(), z.string().regex(SHA256)),
66
86
  }).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
+ }
67
169
  function repoRef(workspaceRoot, absolutePath) {
68
170
  return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
69
171
  }
@@ -112,9 +214,17 @@ export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScri
112
214
  const pythonFiles = await walkPythonFiles(path.resolve(workspaceRoot, "testcase"));
113
215
  const assetFiles = [...new Set(pythonFiles.map((file) => repoRef(workspaceRoot, file)))].sort();
114
216
  const inputHashes = {};
115
- for (const file of assetFiles)
116
- inputHashes[file] = sha256(await readFile(path.resolve(workspaceRoot, file)));
117
- return { mappedScripts: normalizedScripts, assetFiles, 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 };
118
228
  }
119
229
  function bounded(value) {
120
230
  const normalized = value.replaceAll(/(Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+/gi, "$1 [REDACTED]");
@@ -138,6 +248,10 @@ function classifyFixtureResolutionFailure(output) {
138
248
  const normalized = output.replaceAll("\\", "/");
139
249
  const paths = testcasePythonPaths(normalized);
140
250
  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);
141
255
  if (/fixture ['"][^'"]+['"] not found/i.test(normalized) && generatedProviderPaths.length > 0) {
142
256
  return {
143
257
  status: "REPAIRABLE",
@@ -146,6 +260,33 @@ function classifyFixtureResolutionFailure(output) {
146
260
  repairPaths: generatedProviderPaths,
147
261
  };
148
262
  }
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
+ }
149
290
  return {
150
291
  status: "BLOCKED",
151
292
  kind: "unresolved-fixture-dependency",
@@ -199,6 +340,8 @@ export function assessBackendPytestCollection(input) {
199
340
  missingMappedScripts: [],
200
341
  assetFiles: input.inventory.assetFiles,
201
342
  inputHashes: input.inventory.inputHashes,
343
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
344
+ hazardOccurrences: input.inventory.hazardOccurrences,
202
345
  pytestExitCode: input.exitCode,
203
346
  collectedItemCount: items.length,
204
347
  collectedItemIds: items,
@@ -227,6 +370,8 @@ export function assessBackendPytestCollection(input) {
227
370
  missingMappedScripts: [],
228
371
  assetFiles: input.inventory.assetFiles,
229
372
  inputHashes: input.inventory.inputHashes,
373
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
374
+ hazardOccurrences: input.inventory.hazardOccurrences,
230
375
  pytestExitCode: input.exitCode,
231
376
  collectedItemCount: items.length,
232
377
  collectedItemIds: items,
@@ -259,6 +404,8 @@ export function assessBackendPytestCollection(input) {
259
404
  missingMappedScripts: [],
260
405
  assetFiles: input.inventory.assetFiles,
261
406
  inputHashes: input.inventory.inputHashes,
407
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
408
+ hazardOccurrences: input.inventory.hazardOccurrences,
262
409
  pytestExitCode: input.exitCode,
263
410
  collectedItemCount: items.length,
264
411
  collectedItemIds: items,
@@ -294,6 +441,8 @@ export function assessPriorityOnlyBackendPytestModules(input) {
294
441
  missingMappedScripts: [],
295
442
  assetFiles: input.inventory.assetFiles,
296
443
  inputHashes: input.inventory.inputHashes,
444
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
445
+ hazardOccurrences: input.inventory.hazardOccurrences,
297
446
  pytestExitCode: null,
298
447
  collectedItemCount: 0,
299
448
  collectedItemIds: [],
@@ -326,6 +475,8 @@ export function assessMissingBackendPytestScripts(input) {
326
475
  missingMappedScripts: [...input.missingMappedScripts],
327
476
  assetFiles: [...input.assetFiles],
328
477
  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, {}])),
329
480
  pytestExitCode: null,
330
481
  collectedItemCount: 0,
331
482
  collectedItemIds: [],
@@ -415,17 +566,21 @@ async function assertBackendPytestRepairSafety(workspaceRoot, initial, final) {
415
566
  if (JSON.stringify(initial.mappedScripts) !== JSON.stringify(final.mappedScripts)) {
416
567
  throw new Error("backend pytest repair changed mapped script scope");
417
568
  }
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
- ];
424
569
  for (const file of final.assetFiles) {
425
570
  const source = await readFile(path.resolve(workspaceRoot, file), "utf8");
426
- for (const [pattern, label] of forbidden) {
427
- if (pattern.test(source))
428
- throw new Error(`backend pytest repair safety blocked ${label}: ${file}`);
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
+ }
429
584
  }
430
585
  }
431
586
  }
@@ -439,23 +594,65 @@ function assertSameInventory(expected, actual) {
439
594
  throw new Error(`backend pytest collection hash drift: ${file}`);
440
595
  }
441
596
  }
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
+ }
442
642
  export async function materializeBackendTestExecutionReadiness(input) {
443
643
  if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
444
644
  throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
445
645
  }
446
646
  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
- }
451
647
  assertSameInventory(input.effective, current);
452
- const status = input.scenarioParamStatus === "FAIL"
648
+ const eligibility = input.eligibility ?? { eligibleItemIds: input.effective.collectedItemIds, excludedItems: [] };
649
+ const status = eligibility.eligibleItemIds.length === 0
453
650
  ? "BLOCKED"
454
- : input.scenarioParamStatus === "PASS"
651
+ : input.scenarioParamStatus === "PASS" && eligibility.excludedItems.length === 0
455
652
  ? "PASS"
456
653
  : "PARTIAL";
457
654
  const readiness = backendTestExecutionReadinessSchema.parse({
458
- schemaId: "backend-test-execution-readiness-v1",
655
+ schemaId: "backend-test-execution-readiness-v2",
459
656
  status,
460
657
  collectionStatus: "PASS",
461
658
  fixtureResolutionStatus: "PASS",
@@ -466,8 +663,11 @@ export async function materializeBackendTestExecutionReadiness(input) {
466
663
  },
467
664
  mappedScripts: input.effective.mappedScripts,
468
665
  collectedItemIds: input.effective.collectedItemIds,
666
+ eligibleItemIds: eligibility.eligibleItemIds,
667
+ excludedItems: eligibility.excludedItems,
469
668
  fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
470
669
  assetHashes: input.effective.inputHashes,
670
+ eligibilityInputHashes: input.eligibilityInputHashes ?? {},
471
671
  });
472
672
  const contractsDir = path.join(input.runDir, "contracts");
473
673
  await mkdir(contractsDir, { recursive: true });
@@ -477,27 +677,145 @@ export async function materializeBackendTestExecutionReadiness(input) {
477
677
  export async function readBackendTestExecutionReadiness(filePath) {
478
678
  return backendTestExecutionReadinessSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
479
679
  }
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
+ }
480
745
  export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
481
746
  if (!["PASS", "PARTIAL"].includes(readiness.status)) {
482
- throw new Error(`backend pytest execution readiness is ${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}`);
483
773
  }
484
- const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
485
774
  const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
486
775
  if (priorityOnly.length > 0) {
487
- throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
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
+ }
488
801
  }
489
- assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
490
802
  }
491
803
  export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
492
804
  if (effective.phase !== "effective" || effective.status !== "PASS") {
493
805
  throw new Error("backend pytest execution requires effective collection PASS facts");
494
806
  }
495
807
  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(", ")}`);
496
811
  assertSameInventory(effective, current);
497
812
  }
498
813
  export async function materializeEffectiveBackendPytestCollection(input) {
499
814
  if (input.initial.status === "PASS") {
500
815
  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(", ")}`);
501
819
  assertSameInventory(input.initial, current);
502
820
  return backendPytestCollectionFactsSchema.parse({
503
821
  ...input.initial,
@@ -513,6 +831,9 @@ export async function materializeEffectiveBackendPytestCollection(input) {
513
831
  throw new Error("backend pytest collection still fails after bounded repair");
514
832
  await assertBackendPytestRepairSafety(input.workspaceRoot, input.initial, input.final);
515
833
  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(", ")}`);
516
837
  assertSameInventory(input.final, current);
517
838
  return backendPytestCollectionFactsSchema.parse({
518
839
  ...input.final,