@tea-agent/loop-agent 0.17.1 → 0.18.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 (44) hide show
  1. package/CHANGELOG.md +96 -22
  2. package/dist/application/dag/args.js +9 -2
  3. package/dist/executors/dag-pi-executor.js +11 -0
  4. package/dist/executors/shell-executor.js +62 -2
  5. package/dist/governance/spine-audit.js +5 -2
  6. package/dist/worker/console/draft-store.js +81 -3
  7. package/dist/worker/console/interview/grill-me.js +253 -0
  8. package/dist/worker/console/operation-runner.js +2 -1
  9. package/dist/worker/console/operator-actions.js +609 -20
  10. package/dist/worker/console/pi-readiness.js +2 -0
  11. package/dist/worker/console/prd-identity.js +102 -0
  12. package/dist/worker/console/resolve-dag-run-for-task.js +76 -0
  13. package/dist/worker/console/server.js +11 -2
  14. package/dist/worker/console/static/assets/index-KUSib7aM.js +16 -0
  15. package/dist/worker/console/static/assets/index-ucIzpaGJ.css +1 -0
  16. package/dist/worker/console/static/index.html +3 -3
  17. package/dist/worker/console/workflow-kinds.js +46 -0
  18. package/dist/worker/observe/routes.js +8 -1
  19. package/dist/worker/observe/static/index.html +8 -8
  20. package/dist/worker/observe/static/styles.css +296 -181
  21. package/dist/workflows/dag/backend-test-intake-context.js +148 -0
  22. package/dist/workflows/dag/backend-test-markdown-workflow.js +528 -0
  23. package/dist/workflows/dag/init-hybrid.js +93 -109
  24. package/dist/workflows/dag/types.js +4 -0
  25. package/dist/workflows/loop/benchmark.js +1 -1
  26. package/docs/README.md +84 -39
  27. package/docs/architecture/README.md +8 -4
  28. package/docs/architecture/dag-execution.md +8 -1
  29. package/docs/architecture/evolution.md +38 -25
  30. package/docs/architecture/facts-and-state.md +2 -0
  31. package/docs/architecture/system-overview.md +6 -5
  32. package/docs/architecture/worker-and-feature.md +15 -8
  33. package/docs/init-surface.manifest.json +10 -1
  34. package/docs/templates/agent-dag-decision-gate-dogfood-report.md +1 -1
  35. package/docs/templates/backend-test-dag.json +74 -219
  36. package/docs/templates/branch-merge-report.md +1 -1
  37. package/docs/templates/exec-plan.md +2 -0
  38. package/docs/templates/progress-log.md +3 -0
  39. package/package.json +1 -1
  40. package/skills/init-capability-evolution/SKILL.md +1 -1
  41. package/skills/loop-agent/references/command-reference.md +1 -1
  42. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  43. package/dist/worker/console/static/assets/index-BEIdBogJ.js +0 -9
  44. package/dist/worker/console/static/assets/index-Rt0TqimP.css +0 -1
@@ -0,0 +1,148 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access } from "node:fs/promises";
3
+ import path from "node:path";
4
+ const MAX_SOURCE_EXCERPT_CHARS = 2000;
5
+ export const BACKEND_TEST_DISCOVERY_EXCLUDED_DIRECTORIES = [
6
+ ".git",
7
+ ".harness",
8
+ ".env",
9
+ "node_modules",
10
+ "vendor",
11
+ "dist",
12
+ "build",
13
+ "coverage",
14
+ ".venv",
15
+ "venv",
16
+ "__pycache__",
17
+ ];
18
+ const DISCOVERY_CANDIDATES = {
19
+ pytestConfig: ["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"],
20
+ conftest: [
21
+ "conftest.py",
22
+ "tests/conftest.py",
23
+ "test/conftest.py",
24
+ "testcase/conftest.py",
25
+ ],
26
+ testRoot: ["tests", "test", "testcase", "src/tests", "app/tests"],
27
+ package: [
28
+ "package.json",
29
+ "pyproject.toml",
30
+ "requirements.txt",
31
+ "requirements-dev.txt",
32
+ "Pipfile",
33
+ "poetry.lock",
34
+ ],
35
+ serverEntry: [
36
+ "server.py",
37
+ "app.py",
38
+ "main.py",
39
+ "manage.py",
40
+ "src/server.py",
41
+ "src/main.py",
42
+ "app/main.py",
43
+ "server.js",
44
+ "server.ts",
45
+ "src/server.js",
46
+ "src/server.ts",
47
+ ],
48
+ };
49
+ function taskRelative(sources, filePath) {
50
+ return path.relative(sources.taskDir, filePath).replaceAll(path.sep, "/");
51
+ }
52
+ function repoRelativeReadPath(sources, filePath) {
53
+ if (!sources.repoRoot) {
54
+ return filePath.replaceAll(path.sep, "/");
55
+ }
56
+ const relative = path.relative(sources.repoRoot, filePath);
57
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
58
+ throw new Error(`backend-test reference escapes repository root: ${filePath}`);
59
+ }
60
+ return relative.replaceAll(path.sep, "/");
61
+ }
62
+ function boundedExcerpt(markdown, sourcePath) {
63
+ const trimmed = markdown.trim();
64
+ if (trimmed.length <= MAX_SOURCE_EXCERPT_CHARS)
65
+ return trimmed;
66
+ const omitted = trimmed.length - MAX_SOURCE_EXCERPT_CHARS;
67
+ return `${trimmed.slice(0, MAX_SOURCE_EXCERPT_CHARS)}\n\n...[truncated for backend-test intake: showing first ${MAX_SOURCE_EXCERPT_CHARS} of ${trimmed.length} chars; omitted ${omitted} chars]\nFull derived source: ${sourcePath}`;
68
+ }
69
+ function inferRoleHints(referencePath) {
70
+ const normalized = referencePath.toLowerCase();
71
+ const hints = [];
72
+ const add = (hint) => {
73
+ if (!hints.includes(hint))
74
+ hints.push(hint);
75
+ };
76
+ if (/(acceptance|验收|criteria|ac[._-])/.test(normalized))
77
+ add("acceptance-criteria");
78
+ if (/(api|openapi|swagger|endpoint|接口)/.test(normalized))
79
+ add("api-contract");
80
+ if (/(schema|model|field|字段|数据)/.test(normalized))
81
+ add("data-contract");
82
+ if (/(rule|constraint|policy|规则|约束)/.test(normalized))
83
+ add("business-rule");
84
+ if (/(test|pytest|测试)/.test(normalized))
85
+ add("test-evidence");
86
+ if (hints.length === 0)
87
+ add("source-fact");
88
+ return hints;
89
+ }
90
+ async function discoverCandidatePaths(repoRoot) {
91
+ const candidates = {
92
+ pytestConfig: [],
93
+ conftest: [],
94
+ testRoot: [],
95
+ package: [],
96
+ serverEntry: [],
97
+ };
98
+ if (!repoRoot) {
99
+ return {
100
+ mode: "deterministic-fallback",
101
+ candidates,
102
+ excludedDirectories: [...BACKEND_TEST_DISCOVERY_EXCLUDED_DIRECTORIES],
103
+ };
104
+ }
105
+ for (const [kind, paths] of Object.entries(DISCOVERY_CANDIDATES)) {
106
+ for (const candidate of paths) {
107
+ try {
108
+ await access(path.join(repoRoot, candidate));
109
+ candidates[kind].push(candidate);
110
+ }
111
+ catch {
112
+ // A missing bounded candidate is not evidence and is omitted.
113
+ }
114
+ }
115
+ }
116
+ return {
117
+ mode: "filesystem",
118
+ candidates,
119
+ excludedDirectories: [...BACKEND_TEST_DISCOVERY_EXCLUDED_DIRECTORIES],
120
+ };
121
+ }
122
+ export async function buildBackendTestIntakeContext(sources) {
123
+ const requirementPath = taskRelative(sources, sources.requirementPath);
124
+ const constraintPath = taskRelative(sources, sources.constraintPath);
125
+ const parts = [
126
+ "## Derived task contract: 需求.md",
127
+ boundedExcerpt(sources.requirementMarkdown, requirementPath),
128
+ ];
129
+ if (sources.constraintMarkdown) {
130
+ parts.push("## Derived execution constraints: 执行约束.md", boundedExcerpt(sources.constraintMarkdown, constraintPath));
131
+ }
132
+ const referenceIndex = (sources.referenceDocuments ?? [])
133
+ .map((reference) => {
134
+ const relativePath = taskRelative(sources, reference.path);
135
+ return {
136
+ path: relativePath,
137
+ readPath: repoRelativeReadPath(sources, reference.path),
138
+ sha256: createHash("sha256").update(reference.markdown, "utf8").digest("hex"),
139
+ roleHints: inferRoleHints(relativePath),
140
+ };
141
+ })
142
+ .sort((left, right) => left.path.localeCompare(right.path));
143
+ return {
144
+ boundedSourceContext: parts.join("\n\n"),
145
+ referenceIndex,
146
+ projectDiscoverySeed: await discoverCandidatePaths(sources.repoRoot),
147
+ };
148
+ }
@@ -0,0 +1,528 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ const CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/g;
5
+ const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
6
+ const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
7
+ const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|后续补充|暂不实现|接口正常|结果正确|按实际情况处理/i;
8
+ const REQUIRED_CASE_SECTIONS = [
9
+ "Acceptance Criteria",
10
+ "Source References",
11
+ "Preconditions",
12
+ "Steps",
13
+ "Expected Results",
14
+ "Automation Notes",
15
+ ];
16
+ async function exists(filePath) {
17
+ try {
18
+ await access(filePath);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ async function existingPaths(root, candidates) {
26
+ const found = [];
27
+ for (const candidate of candidates) {
28
+ if (await exists(path.join(root, candidate)))
29
+ found.push(candidate);
30
+ }
31
+ return found;
32
+ }
33
+ function unique(values) {
34
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
35
+ }
36
+ export function requiredBackendMarkdownCaseAcIds(criteria) {
37
+ const downstreamEvidence = /(?:pytest|junit|html|stdout|stderr|traceability|追溯|一一映射|一一追溯|执行并产生|执行结果|测试报告|失败分类|证据)/i;
38
+ return unique([...criteria]
39
+ .filter((criterion) => !downstreamEvidence.test(criterion))
40
+ .flatMap((criterion) => criterion.match(/\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []));
41
+ }
42
+ function extractFixtures(markdown) {
43
+ return unique([
44
+ ...markdown.matchAll(/(?:@pytest\.fixture(?:\([^)]*\))?\s*(?:\r?\n)+\s*def\s+|def\s+)([A-Za-z_][A-Za-z0-9_]*)\s*\(/g),
45
+ ]
46
+ .map((match) => match[1])
47
+ .filter(Boolean));
48
+ }
49
+ export async function inspectBackendTestEnvironment(input) {
50
+ const pytestConfigs = await existingPaths(input.workspaceRoot, [
51
+ "pytest.ini",
52
+ "pyproject.toml",
53
+ "setup.cfg",
54
+ "tox.ini",
55
+ ]);
56
+ const testRoots = await existingPaths(input.workspaceRoot, [
57
+ "testcase",
58
+ "tests",
59
+ "test",
60
+ "src/tests",
61
+ "app/tests",
62
+ ]);
63
+ const conftestFiles = await existingPaths(input.workspaceRoot, [
64
+ "conftest.py",
65
+ "testcase/conftest.py",
66
+ "tests/conftest.py",
67
+ "test/conftest.py",
68
+ ]);
69
+ const serverEntries = await existingPaths(input.workspaceRoot, [
70
+ "server.py",
71
+ "app.py",
72
+ "main.py",
73
+ "manage.py",
74
+ "src/server.py",
75
+ "src/main.py",
76
+ "app/main.py",
77
+ "server.js",
78
+ "server.ts",
79
+ "src/server.js",
80
+ "src/server.ts",
81
+ ]);
82
+ const fixtures = [];
83
+ for (const relativePath of conftestFiles) {
84
+ const content = await readFile(path.join(input.workspaceRoot, relativePath), "utf8");
85
+ if (SECRET.test(content)) {
86
+ // Do not copy conftest content into evidence. Fixture names are still safe to discover.
87
+ }
88
+ fixtures.push(...extractFixtures(content));
89
+ }
90
+ const htmlRenderer = /--html\b/.test(input.pytestHelp)
91
+ ? "pytest-html --self-contained-html"
92
+ : "loop-agent built-in JUnit-to-HTML";
93
+ const warnings = [
94
+ ...(pytestConfigs.length === 0
95
+ ? ["No explicit pytest configuration file was found."]
96
+ : []),
97
+ ...(conftestFiles.length === 0
98
+ ? ["No bounded conftest.py candidate was found."]
99
+ : []),
100
+ ...(serverEntries.length === 0
101
+ ? [
102
+ "No bounded server entry candidate was found; Markdown cases must document an external or library target explicitly.",
103
+ ]
104
+ : []),
105
+ ];
106
+ const lines = [
107
+ "# Backend Test Environment",
108
+ "",
109
+ "## Status",
110
+ "",
111
+ "PASS",
112
+ "",
113
+ "## Runtime",
114
+ "",
115
+ `- Python: ${input.pythonVersion.trim()}`,
116
+ `- Pytest: ${input.pytestVersion.trim()}`,
117
+ "- Shell environment: clean",
118
+ "",
119
+ "## Project Discovery",
120
+ "",
121
+ `- Pytest config: ${pytestConfigs.join(", ") || "not found"}`,
122
+ `- Existing test roots: ${testRoots.join(", ") || "not found (testcase will be generated)"}`,
123
+ `- Conftest files: ${conftestFiles.join(", ") || "not found"}`,
124
+ `- Server entry candidates: ${serverEntries.join(", ") || "not found"}`,
125
+ `- Fixtures: ${unique(fixtures).join(", ") || "none discovered"}`,
126
+ "",
127
+ "## HTML Report",
128
+ "",
129
+ `- Selected renderer: ${htmlRenderer}`,
130
+ "",
131
+ "## Bounded Discovery Policy",
132
+ "",
133
+ "- No .env value, credential file, dependency tree, build output, coverage output, or runtime directory was inspected.",
134
+ "- External readiness must be represented by an explicitly configured verify command or a documented safe health endpoint; secret values are never printed.",
135
+ "",
136
+ "## Warnings",
137
+ "",
138
+ ...(warnings.length > 0
139
+ ? warnings.map((warning) => `- ${warning}`)
140
+ : ["- None"]),
141
+ ];
142
+ return {
143
+ markdown: `${lines.join("\n")}\n`,
144
+ fixtures: unique(fixtures),
145
+ pytestConfigs,
146
+ testRoots,
147
+ conftestFiles,
148
+ serverEntries,
149
+ };
150
+ }
151
+ async function markdownFiles(root) {
152
+ const directory = path.join(root, "testcase", "md");
153
+ const entries = await readdir(directory, { withFileTypes: true });
154
+ return entries
155
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
156
+ .map((entry) => path.join(directory, entry.name))
157
+ .sort();
158
+ }
159
+ function splitCases(markdown) {
160
+ const headings = [
161
+ ...markdown.matchAll(/^##\s+(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3})\b.*$/gm),
162
+ ];
163
+ return headings.map((match, index) => ({
164
+ id: match[1],
165
+ body: markdown.slice(match.index, headings[index + 1]?.index ?? markdown.length),
166
+ }));
167
+ }
168
+ function sectionBody(body, heading) {
169
+ const marker = new RegExp(`^###\\s+${heading}\\s*$`, "mi");
170
+ const match = marker.exec(body);
171
+ if (!match)
172
+ return "";
173
+ const remainder = body.slice(match.index + match[0].length);
174
+ const next = /^###\s+/m.exec(remainder);
175
+ return remainder.slice(0, next?.index ?? remainder.length);
176
+ }
177
+ function escapeRegExp(value) {
178
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
179
+ }
180
+ function isTaskRelativeSourcePath(value) {
181
+ return /^source\/(?:需求\.md|执行约束\.md|references\/[^\s`'"()[\]{}()。,;:#]+)$/.test(value);
182
+ }
183
+ function extractLegacySourceReferences(sourceSection) {
184
+ const inlineCode = [
185
+ ...sourceSection.matchAll(/`(source\/(?:需求\.md|执行约束\.md|references\/[^`\r\n]+))`/g),
186
+ ].map((match) => match[1].trim());
187
+ const plain = [
188
+ ...sourceSection.matchAll(/(?:^|[\s('"(])(source\/(?:需求\.md|执行约束\.md|references\/[^\s`'"()[\]{}()。,;:#]+))(?=$|[\s`'"()()\],。;:#])/gm),
189
+ ].map((match) => match[1]);
190
+ return unique([...inlineCode, ...plain].filter(isTaskRelativeSourcePath));
191
+ }
192
+ function extractSourceReferences(input) {
193
+ const citedPaths = extractLegacySourceReferences(input.sourceSection);
194
+ if (!input.sourceBinding) {
195
+ return citedPaths;
196
+ }
197
+ const boundMatches = input.sourceBinding.sources
198
+ .map((source) => source.path)
199
+ .filter(isTaskRelativeSourcePath)
200
+ .filter((sourcePath) => {
201
+ const escaped = escapeRegExp(sourcePath);
202
+ return new RegExp(`(?:^|[\\s(\`'\"(])${escaped}(?=$|[\\s)\`'\")\\],。;:#])`, "m").test(input.sourceSection);
203
+ });
204
+ // Preserve explicitly cited unknown paths so resolveBoundSourceReference()
205
+ // rejects them instead of silently accepting a nearby bound prefix.
206
+ return unique([...boundMatches, ...citedPaths]);
207
+ }
208
+ function hasNumberedListItem(value) {
209
+ return /^\s*\d+[.)]\s+\S+/m.test(value);
210
+ }
211
+ function hasAssertableExpectedResult(value) {
212
+ return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value);
213
+ }
214
+ function resolveBoundSourceReference(input) {
215
+ if (!input.sourceBinding) {
216
+ return path.join(input.workspaceRoot, input.sourceRef);
217
+ }
218
+ const member = input.sourceBinding.sources.find((source) => source.path === input.sourceRef);
219
+ if (!member) {
220
+ throw new Error(`source reference is not present in DAG sourceBinding: ${input.sourceRef}`);
221
+ }
222
+ const taskRoot = path.join(input.workspaceRoot, ".harness", "tasks", input.sourceBinding.taskId);
223
+ const resolved = path.resolve(taskRoot, member.path);
224
+ const relative = path.relative(taskRoot, resolved);
225
+ if (relative.startsWith("..") ||
226
+ path.isAbsolute(relative)) {
227
+ throw new Error(`unsafe task-relative source path: ${input.sourceRef}`);
228
+ }
229
+ return resolved;
230
+ }
231
+ function leadingWhitespace(value) {
232
+ return value.match(/^\s*/)?.[0].length ?? 0;
233
+ }
234
+ function isCleanupStatement(value) {
235
+ const trimmed = value.trim();
236
+ if (!trimmed || trimmed.startsWith("#"))
237
+ return true;
238
+ if (/^(?:with|if)\b/.test(trimmed))
239
+ return false;
240
+ if (/\b(?:assert|pytest\.fail|raise)\b/.test(trimmed))
241
+ return false;
242
+ return /(?:^|[._])(?:cleanup|clean_up|teardown|delete|remove|close|release|rollback|disconnect|dispose|unlink)(?:[_(.]|$)/i.test(trimmed);
243
+ }
244
+ function findUnsafeSwallowedException(source) {
245
+ const lines = source.split(/\r?\n/);
246
+ for (let index = 0; index < lines.length; index += 1) {
247
+ const exceptMatch = lines[index].match(/^(\s*)except(?:\s+([^:]+))?\s*:\s*(?:(pass)\s*)?(?:#.*)?$/);
248
+ if (!exceptMatch)
249
+ continue;
250
+ const indent = exceptMatch[1].length;
251
+ const inlinePass = exceptMatch[3] === "pass";
252
+ let handlerIndex = index + 1;
253
+ while (!inlinePass &&
254
+ handlerIndex < lines.length &&
255
+ (!lines[handlerIndex].trim() || lines[handlerIndex].trim().startsWith("#"))) {
256
+ handlerIndex += 1;
257
+ }
258
+ if (!inlinePass &&
259
+ (handlerIndex >= lines.length ||
260
+ leadingWhitespace(lines[handlerIndex]) <= indent ||
261
+ lines[handlerIndex].trim() !== "pass")) {
262
+ continue;
263
+ }
264
+ const exceptionType = exceptMatch[2]?.trim() || "bare except";
265
+ if (exceptionType !== "requests.RequestException") {
266
+ return exceptionType;
267
+ }
268
+ let tryIndex = index - 1;
269
+ while (tryIndex >= 0) {
270
+ const candidate = lines[tryIndex];
271
+ if (!candidate.trim() || candidate.trim().startsWith("#")) {
272
+ tryIndex -= 1;
273
+ continue;
274
+ }
275
+ if (leadingWhitespace(candidate) === indent &&
276
+ candidate.trim() === "try:") {
277
+ break;
278
+ }
279
+ if (leadingWhitespace(candidate) <= indent)
280
+ break;
281
+ tryIndex -= 1;
282
+ }
283
+ if (tryIndex < 0 || lines[tryIndex].trim() !== "try:") {
284
+ return exceptionType;
285
+ }
286
+ const tryBody = lines.slice(tryIndex + 1, index);
287
+ const executable = tryBody.filter((line) => line.trim() && !line.trim().startsWith("#"));
288
+ if (executable.length === 0 ||
289
+ executable.some((line) => leadingWhitespace(line) <= indent || !isCleanupStatement(line))) {
290
+ return exceptionType;
291
+ }
292
+ }
293
+ return null;
294
+ }
295
+ export async function validateBackendMarkdownCases(input) {
296
+ const files = await markdownFiles(input.workspaceRoot);
297
+ const readme = path.join(input.workspaceRoot, "testcase", "md", "README.md");
298
+ if (!(await exists(readme)))
299
+ throw new Error("missing testcase/md/README.md");
300
+ const moduleFiles = files.filter((file) => path.basename(file).toLowerCase() !== "readme.md");
301
+ if (moduleFiles.length === 0)
302
+ throw new Error("no testcase/md module case file found");
303
+ const seen = new Map();
304
+ const coveredAc = new Set();
305
+ let caseCount = 0;
306
+ for (const file of moduleFiles) {
307
+ const markdown = await readFile(file, "utf8");
308
+ if (!markdown.trim())
309
+ throw new Error(`empty Markdown case file: ${path.relative(input.workspaceRoot, file)}`);
310
+ if (SECRET.test(markdown))
311
+ throw new Error(`secret-shaped content in ${path.relative(input.workspaceRoot, file)}`);
312
+ if (PLACEHOLDER.test(markdown))
313
+ throw new Error(`placeholder or non-assertable wording in ${path.relative(input.workspaceRoot, file)}`);
314
+ const cases = splitCases(markdown);
315
+ if (cases.length === 0)
316
+ throw new Error(`no BE-<MODULE>-<NNN> case heading in ${path.relative(input.workspaceRoot, file)}`);
317
+ for (const testCase of cases) {
318
+ caseCount += 1;
319
+ const previous = seen.get(testCase.id);
320
+ if (previous)
321
+ throw new Error(`duplicate case id ${testCase.id}: ${previous} and ${path.relative(input.workspaceRoot, file)}`);
322
+ seen.set(testCase.id, path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/"));
323
+ for (const section of REQUIRED_CASE_SECTIONS) {
324
+ if (!new RegExp(`^###\\s+${section}\\s*$`, "mi").test(testCase.body)) {
325
+ throw new Error(`${testCase.id} missing section: ${section}`);
326
+ }
327
+ }
328
+ const acIds = unique(testCase.body.match(AC_ID) ?? []);
329
+ if (acIds.length === 0)
330
+ throw new Error(`${testCase.id} has no AC-* reference`);
331
+ acIds.forEach((id) => coveredAc.add(id));
332
+ const sourceSection = sectionBody(testCase.body, "Source References");
333
+ const sourceRefs = extractSourceReferences({
334
+ sourceSection,
335
+ sourceBinding: input.sourceBinding,
336
+ });
337
+ if (sourceRefs.length === 0) {
338
+ throw new Error(`${testCase.id} has no task-relative source reference`);
339
+ }
340
+ for (const sourceRef of sourceRefs) {
341
+ if (/\.env(?:\.|$)|credential|secret/i.test(sourceRef)) {
342
+ throw new Error(`${testCase.id} references a forbidden source path: ${sourceRef}`);
343
+ }
344
+ const resolvedSourcePath = resolveBoundSourceReference({
345
+ workspaceRoot: input.workspaceRoot,
346
+ sourceRef,
347
+ sourceBinding: input.sourceBinding,
348
+ });
349
+ if (!(await exists(resolvedSourcePath))) {
350
+ throw new Error(`${testCase.id} references a missing source path: ${sourceRef}`);
351
+ }
352
+ }
353
+ const steps = sectionBody(testCase.body, "Steps");
354
+ const expected = sectionBody(testCase.body, "Expected Results");
355
+ if (!hasNumberedListItem(steps))
356
+ throw new Error(`${testCase.id} has no numbered executable step`);
357
+ if (!hasAssertableExpectedResult(expected))
358
+ throw new Error(`${testCase.id} has no structured expected result`);
359
+ }
360
+ }
361
+ const missing = input.requiredRequirementIds.filter((id) => !coveredAc.has(id));
362
+ if (missing.length > 0)
363
+ throw new Error(`required AC IDs are not covered by final Markdown cases: ${missing.join(", ")}`);
364
+ const report = [
365
+ "# Backend Markdown Case Validation",
366
+ "",
367
+ "## Status",
368
+ "",
369
+ "PASS",
370
+ "",
371
+ `- Markdown files: ${files.length}`,
372
+ `- Module files: ${moduleFiles.length}`,
373
+ `- Cases: ${caseCount}`,
374
+ `- Covered AC IDs: ${[...coveredAc].sort().join(", ") || "none"}`,
375
+ `- Environment evidence present: ${input.environmentMarkdown.trim() ? "yes" : "no"}`,
376
+ "- Duplicate case IDs: 0",
377
+ "- Secret-shaped findings: 0",
378
+ ];
379
+ return `${report.join("\n")}\n`;
380
+ }
381
+ export async function validateBackendMarkdownTraceability(workspaceRoot) {
382
+ const files = await markdownFiles(workspaceRoot);
383
+ const markdownIds = new Set();
384
+ for (const file of files) {
385
+ const content = await readFile(file, "utf8");
386
+ for (const id of content.match(CASE_ID) ?? [])
387
+ markdownIds.add(id);
388
+ }
389
+ const testcaseRoot = path.join(workspaceRoot, "testcase");
390
+ const pythonFiles = [];
391
+ async function walk(directory) {
392
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
393
+ const absolute = path.join(directory, entry.name);
394
+ if (entry.isDirectory())
395
+ await walk(absolute);
396
+ else if (/^test_.*\.py$/i.test(entry.name))
397
+ pythonFiles.push(absolute);
398
+ }
399
+ }
400
+ await walk(testcaseRoot);
401
+ const symbols = new Map();
402
+ for (const file of pythonFiles) {
403
+ const content = await readFile(file, "utf8");
404
+ if (/pytest\.(?:skip|xfail)\s*\(|@pytest\.mark\.(?:skip|xfail)\b/.test(content)) {
405
+ throw new Error(`skip/xfail is forbidden in generated backend pytest: ${path.relative(workspaceRoot, file)}`);
406
+ }
407
+ const unsafeSwallowedException = findUnsafeSwallowedException(content);
408
+ if (unsafeSwallowedException) {
409
+ throw new Error(`swallowed exception is forbidden in generated backend pytest: ${path.relative(workspaceRoot, file)} (${unsafeSwallowedException})`);
410
+ }
411
+ for (const match of content.matchAll(/^(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
412
+ const symbol = match[1];
413
+ const matchingMarkdownIds = [...markdownIds].filter((caseId) => {
414
+ const prefix = `test_${caseId.replaceAll("-", "_")}`;
415
+ return symbol === prefix || symbol.startsWith(`${prefix}_`);
416
+ });
417
+ if (matchingMarkdownIds.length > 1) {
418
+ throw new Error(`pytest symbol matches multiple Markdown Case IDs: ${symbol} -> ${matchingMarkdownIds.join(",")}`);
419
+ }
420
+ const fallback = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{3})(?:_|$)/i);
421
+ const id = matchingMarkdownIds[0] ??
422
+ (fallback
423
+ ? fallback[1].replaceAll("_", "-").toUpperCase()
424
+ : undefined);
425
+ if (!id)
426
+ continue;
427
+ const bucket = symbols.get(id) ?? [];
428
+ bucket.push(`${path.relative(workspaceRoot, file).replaceAll(path.sep, "/")}#${symbol}`);
429
+ symbols.set(id, bucket);
430
+ const tail = content.slice(match.index + match[0].length, match.index + match[0].length + 500);
431
+ if (!new RegExp(`^[\\s\\r\\n]*(?:[rRuUfF]{0,2})?[\"']{3}[^\n]*${id}`, "m").test(tail)) {
432
+ throw new Error(`${id} pytest symbol is missing a matching first-line docstring: ${bucket.at(-1)}`);
433
+ }
434
+ }
435
+ }
436
+ const missing = [...markdownIds].filter((id) => !symbols.has(id));
437
+ const duplicates = [...symbols]
438
+ .filter(([, refs]) => refs.length !== 1)
439
+ .map(([id]) => id);
440
+ const extra = [...symbols.keys()].filter((id) => !markdownIds.has(id));
441
+ if (missing.length || duplicates.length || extra.length) {
442
+ throw new Error(`traceability mismatch: missing=${missing.join(",") || "none"}; duplicates=${duplicates.join(",") || "none"}; extra=${extra.join(",") || "none"}`);
443
+ }
444
+ return ([
445
+ "# Backend Test Traceability",
446
+ "",
447
+ "## Status",
448
+ "",
449
+ "PASS",
450
+ "",
451
+ `- Markdown Case IDs: ${markdownIds.size}`,
452
+ `- Pytest symbols: ${symbols.size}`,
453
+ `- Matched symbols: ${symbols.size}`,
454
+ "- Missing mappings: 0",
455
+ "- Extra symbols: 0",
456
+ "- Duplicate symbols: 0",
457
+ "- skip/xfail findings: 0",
458
+ ].join("\n") + "\n");
459
+ }
460
+ export function redactBackendTestOutput(value) {
461
+ return value
462
+ .replace(/((?:password|passwd|secret|token|api[_-]?key|authorization)\s*[:=]\s*)\S+/gi, "$1[REDACTED]")
463
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
464
+ }
465
+ function escapeHtml(value) {
466
+ return value
467
+ .replaceAll("&", "&amp;")
468
+ .replaceAll("<", "&lt;")
469
+ .replaceAll(">", "&gt;")
470
+ .replaceAll('"', "&quot;");
471
+ }
472
+ export function renderBackendTestHtml(input) {
473
+ const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
474
+ const failures = input.parsed.failures.length
475
+ ? input.parsed.failures
476
+ .map((failure) => `<tr><td>${escapeHtml(failure.name)}</td><td>${escapeHtml(failure.kind)}</td><td><pre>${escapeHtml(failure.message)}</pre></td></tr>`)
477
+ .join("")
478
+ : '<tr><td colspan="3">No failures</td></tr>';
479
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(input.title)}</title><style>body{font-family:system-ui,"Microsoft YaHei",sans-serif;margin:2rem;color:#172033}h1,h2{color:#17365d}.cards{display:grid;grid-template-columns:repeat(3,minmax(8rem,1fr));gap:.75rem}.card{padding:1rem;border:1px solid #ccd6e0;border-radius:.5rem;background:#f7f9fc}.bad{color:#b42318}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccd6e0;padding:.55rem;text-align:left;vertical-align:top}pre{white-space:pre-wrap;max-height:18rem;overflow:auto}</style></head><body><h1>${escapeHtml(input.title)}</h1><div class="cards"><div class="card">Total<br><strong>${input.parsed.tests}</strong></div><div class="card">Passed<br><strong>${input.parsed.passed}</strong></div><div class="card bad">Failed/Error<br><strong>${input.parsed.failed + input.parsed.errors}</strong></div><div class="card">Skipped<br><strong>${input.parsed.skipped}</strong></div><div class="card">Pass rate<br><strong>${(passRate * 100).toFixed(2)}%</strong></div><div class="card">Duration<br><strong>${input.parsed.durationMs ?? "unavailable"} ms</strong></div></div><h2>Environment</h2><pre>${escapeHtml(input.environmentSummary.slice(0, 4000))}</pre><h2>Traceability</h2><pre>${escapeHtml(input.traceabilitySummary.slice(0, 4000))}</pre><h2>Failures</h2><table><thead><tr><th>Test</th><th>Kind</th><th>Summary</th></tr></thead><tbody>${failures}</tbody></table></body></html>`;
480
+ }
481
+ export function renderBackendTestFacts(input) {
482
+ const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
483
+ return ([
484
+ "# Backend Test Execution Facts",
485
+ "",
486
+ "## Execution",
487
+ "",
488
+ `- Status: ${input.pytestExitCode === 0 ? "passed" : "completed-with-failures"}`,
489
+ `- Pytest exit code: ${input.pytestExitCode}`,
490
+ `- Duration: ${input.parsed.durationMs ?? "unavailable"} ms`,
491
+ "- JUnit valid: yes",
492
+ "- HTML valid: yes",
493
+ "",
494
+ "## Counts",
495
+ "",
496
+ `- Total: ${input.parsed.tests}`,
497
+ `- Passed: ${input.parsed.passed}`,
498
+ `- Failed: ${input.parsed.failed}`,
499
+ `- Errors: ${input.parsed.errors}`,
500
+ `- Skipped: ${input.parsed.skipped}`,
501
+ `- Pass rate: ${(passRate * 100).toFixed(2)}%`,
502
+ "",
503
+ "## Evidence",
504
+ "",
505
+ `- JUnit: ${input.junitRelativePath}`,
506
+ `- JUnit SHA-256: ${createHash("sha256").update(input.junitContent).digest("hex")}`,
507
+ `- HTML: ${input.htmlRelativePath}`,
508
+ `- HTML SHA-256: ${createHash("sha256").update(input.htmlContent).digest("hex")}`,
509
+ "",
510
+ "## Failed Tests",
511
+ "",
512
+ ...(input.parsed.failures.length > 0
513
+ ? input.parsed.failures.map((failure) => `- ${failure.name} [${failure.kind}]: ${failure.message}`)
514
+ : ["- None"]),
515
+ "",
516
+ "## Maturity Evidence",
517
+ "",
518
+ "- Code coverage: unavailable unless a separate validated coverage artifact exists.",
519
+ "- Stability: unavailable unless at least five independent runs are recorded.",
520
+ ].join("\n") + "\n");
521
+ }
522
+ export async function writeRunReport(runDir, fileName, content) {
523
+ const reportsDir = path.join(runDir, "reports");
524
+ await mkdir(reportsDir, { recursive: true });
525
+ const absolute = path.join(reportsDir, fileName);
526
+ await writeFile(absolute, content, "utf8");
527
+ return absolute;
528
+ }