@tea-agent/loop-agent 0.24.4 → 0.24.6

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 (31) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +5 -2
  4. package/dist/application/dag/generate-task-dag.js +5 -8
  5. package/dist/commands/init.js +19 -5
  6. package/dist/executors/shell-executor.js +10 -2
  7. package/dist/task/task-demand-routing.js +27 -14
  8. package/dist/worker/cli.js +59 -14
  9. package/dist/worker/observe/dag-run-artifacts.js +90 -0
  10. package/dist/worker/observe/node-input.js +444 -0
  11. package/dist/worker/observe/routes.js +17 -0
  12. package/dist/worker/observe/static/api.js +9 -0
  13. package/dist/worker/observe/static/constants.js +9 -0
  14. package/dist/worker/observe/static/state.js +14 -0
  15. package/dist/worker/observe/static/styles.css +74 -0
  16. package/dist/worker/observe/static/views/dag-inspector.js +371 -15
  17. package/dist/workflows/dag/backend-test-markdown-workflow.js +75 -9
  18. package/dist/workflows/dag/backend-test-result-contract.js +103 -0
  19. package/dist/workflows/dag/init-hybrid.js +9 -8
  20. package/dist/workflows/dag/node-execution.js +3 -2
  21. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -2
  22. package/docs/templates/backend-test-dag.json +7 -7
  23. package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
  24. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  25. package/docs/templates/evaluation/agents-map-verbose-v0.md +2 -2
  26. package/harness.json +1 -1
  27. package/package.json +1 -1
  28. package/skills/agent-worker/SKILL.md +1 -1
  29. package/skills/loop-agent/SKILL.md +1 -1
  30. package/skills/loop-agent/references/command-reference.md +3 -2
  31. package/skills/loop-agent/references/hybrid-dag.md +2 -2
@@ -10,12 +10,13 @@ import {
10
10
  UI_TEXT,
11
11
  } from "../constants.js";
12
12
  import { clearNode, el } from "../dom.js";
13
- import { fetchJson, fetchSpecEvidenceFile } from "../api.js";
13
+ import { fetchJson, fetchSpecEvidenceFile, fetchDagNodeInput } from "../api.js";
14
14
  import { badge } from "../format.js";
15
15
  import { renderMarkdown } from "../markdown-render.js";
16
16
  import {
17
17
  uiState,
18
18
  makeSessionEventIdentity,
19
+ makeDagNodeInputIdentity,
19
20
  openSpecEvidenceDetail,
20
21
  closeSpecEvidenceDetail,
21
22
  updateDagTimelineViewportState,
@@ -50,9 +51,13 @@ export function selectDagNode(dagRunId, nodeId) {
50
51
  // into node B's spec-evidence panel (AC-007 regression guard).
51
52
  uiState.specEvidenceDetail = null;
52
53
  uiState.specEvidenceListEvidence = null;
54
+ uiState.dagNodeInputData = null;
55
+ uiState.dagNodeInputError = null;
56
+ uiState.dagNodeInputLoading = false;
53
57
  // Bump the session-event identity token so any in-flight response for the
54
58
  // previous node is rejected before it can mutate the current node cache.
55
59
  uiState.sessionEventIdentity = makeSessionEventIdentity(dagRunId, nodeId);
60
+ uiState.dagNodeInputIdentity = makeDagNodeInputIdentity(dagRunId, nodeId);
56
61
  }
57
62
  void import("./dag.js").then((m) => m.renderDagDetail(dagRunId, false));
58
63
  }
@@ -530,16 +535,337 @@ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
530
535
  content.appendChild(wrapper);
531
536
  }
532
537
 
538
+ function sourceLabel(source) {
539
+ if (source === "defaults") return "继承默认值";
540
+ if (source === "schema-default") return "schema 默认";
541
+ if (source === "merged") return "合并";
542
+ return null;
543
+ }
544
+
545
+ function appendMetaRow(dl, label, value, source) {
546
+ if (value == null || value === "") return;
547
+ const dt = el("dt", null, label);
548
+ const dd = el("dd", null, String(value));
549
+ const inherited = sourceLabel(source);
550
+ if (inherited) {
551
+ dd.appendChild(document.createTextNode(" "));
552
+ dd.appendChild(el("span", "node-input-inherited", inherited));
553
+ }
554
+ dl.appendChild(dt);
555
+ dl.appendChild(dd);
556
+ }
557
+
558
+ function appendCodeList(parent, title, values) {
559
+ if (!values || values.length === 0) return;
560
+ const section = el("section", "node-input-section");
561
+ section.appendChild(el("h4", null, title));
562
+ const list = el("ul", "node-input-code-list");
563
+ for (const value of values) {
564
+ const item = document.createElement("li");
565
+ const code = document.createElement("code");
566
+ code.textContent = String(value);
567
+ item.appendChild(code);
568
+ list.appendChild(item);
569
+ }
570
+ section.appendChild(list);
571
+ parent.appendChild(section);
572
+ }
573
+
574
+ function renderDagNodeInputDetail(content, data) {
575
+ const wrap = el("div", "node-input-root");
576
+ wrap.appendChild(
577
+ el("p", "node-input-semantics", UI_TEXT.inputFrozenLabel),
578
+ );
579
+
580
+ if (data.summary) {
581
+ const section = el("section", "node-input-section");
582
+ section.appendChild(el("h4", null, "摘要"));
583
+ const dl = el("dl", "node-input-summary");
584
+ appendMetaRow(
585
+ dl,
586
+ "executor",
587
+ data.summary.executor,
588
+ data.summary.executorSource,
589
+ );
590
+ appendMetaRow(dl, "role", data.summary.role, null);
591
+ appendMetaRow(dl, "complexity", data.summary.complexity, null);
592
+ appendMetaRow(
593
+ dl,
594
+ "writePolicy",
595
+ data.summary.writePolicy,
596
+ data.summary.writePolicySource,
597
+ );
598
+ appendMetaRow(dl, "toolProfile", data.summary.toolProfile, null);
599
+ section.appendChild(dl);
600
+ wrap.appendChild(section);
601
+ }
602
+
603
+ if (data.dependency) {
604
+ const section = el("section", "node-input-section");
605
+ section.appendChild(el("h4", null, "依赖"));
606
+ const dl = el("dl", "node-input-summary");
607
+ appendMetaRow(
608
+ dl,
609
+ "depends_on",
610
+ (data.dependency.dependsOn ?? []).join(", ") || "(none)",
611
+ null,
612
+ );
613
+ if (data.dependency.dependsPolicy) {
614
+ appendMetaRow(dl, "dependsPolicy", data.dependency.dependsPolicy, null);
615
+ }
616
+ if ((data.dependency.failureAwareDependsOn ?? []).length > 0) {
617
+ appendMetaRow(
618
+ dl,
619
+ "failureAwareDependsOn",
620
+ data.dependency.failureAwareDependsOn.join(", "),
621
+ null,
622
+ );
623
+ }
624
+ if (data.dependency.runIf) {
625
+ appendMetaRow(dl, "runIf", data.dependency.runIf, null);
626
+ }
627
+ section.appendChild(dl);
628
+ wrap.appendChild(section);
629
+ }
630
+
631
+ if (data.prompt) {
632
+ const section = el("section", "node-input-section");
633
+ section.appendChild(el("h4", null, "任务正文"));
634
+ if (data.prompt.source?.path) {
635
+ const meta = el("p", "muted");
636
+ meta.textContent = `source: ${data.prompt.source.path}${
637
+ data.prompt.source.sha256Prefix
638
+ ? ` (sha256 ${data.prompt.source.sha256Prefix}…)`
639
+ : ""
640
+ }`;
641
+ section.appendChild(meta);
642
+ }
643
+ section.appendChild(renderMarkdown(data.prompt.text ?? ""));
644
+ if (data.prompt.truncated) {
645
+ section.appendChild(
646
+ el(
647
+ "p",
648
+ "node-input-truncated",
649
+ `已截断,仅显示前 ${data.prompt.maxBytes ?? 0} 字节(UTF-8)。`,
650
+ ),
651
+ );
652
+ }
653
+ wrap.appendChild(section);
654
+ }
655
+
656
+ if (data.executorInput) {
657
+ const section = el("section", "node-input-section");
658
+ section.appendChild(el("h4", null, "执行器输入摘要"));
659
+ const dl = el("dl", "node-input-summary");
660
+ appendMetaRow(dl, "kind", data.executorInput.kind, null);
661
+ if (data.executorInput.modelHint) {
662
+ appendMetaRow(dl, "modelHint", data.executorInput.modelHint, null);
663
+ }
664
+ section.appendChild(dl);
665
+ appendCodeList(section, "skills", data.executorInput.skills);
666
+ if (data.executorInput.shell) {
667
+ const shell = data.executorInput.shell;
668
+ const shellDl = el("dl", "node-input-summary");
669
+ appendMetaRow(shellDl, "preset", shell.preset, null);
670
+ appendMetaRow(shellDl, "cwd", shell.cwd, null);
671
+ appendMetaRow(shellDl, "timeoutMs", shell.timeoutMs, null);
672
+ appendMetaRow(
673
+ shellDl,
674
+ "nonZeroExitPolicy",
675
+ shell.nonZeroExitPolicy,
676
+ null,
677
+ );
678
+ appendMetaRow(
679
+ shellDl,
680
+ "gates",
681
+ (shell.gates ?? []).join(", ") || "(none)",
682
+ null,
683
+ );
684
+ section.appendChild(shellDl);
685
+ if ((shell.commands ?? []).length > 0) {
686
+ const list = el("ol", "node-input-command-list");
687
+ for (const command of shell.commands) {
688
+ const item = document.createElement("li");
689
+ const pre = document.createElement("pre");
690
+ const code = document.createElement("code");
691
+ code.textContent = String(command);
692
+ pre.appendChild(code);
693
+ item.appendChild(pre);
694
+ list.appendChild(item);
695
+ }
696
+ section.appendChild(list);
697
+ }
698
+ }
699
+ if (data.executorInput.static) {
700
+ const staticBlock = data.executorInput.static;
701
+ const staticDl = el("dl", "node-input-summary");
702
+ appendMetaRow(staticDl, "status", staticBlock.status, null);
703
+ section.appendChild(staticDl);
704
+ section.appendChild(renderMarkdown(staticBlock.resultPreview ?? ""));
705
+ if (staticBlock.truncated) {
706
+ section.appendChild(
707
+ el(
708
+ "p",
709
+ "node-input-truncated",
710
+ `已截断,仅显示前 ${staticBlock.maxBytes ?? 0} 字节(UTF-8)。`,
711
+ ),
712
+ );
713
+ }
714
+ }
715
+ wrap.appendChild(section);
716
+ }
717
+
718
+ if (data.boundaries) {
719
+ const section = el("section", "node-input-section");
720
+ section.appendChild(el("h4", null, "边界与输出契约"));
721
+ const dl = el("dl", "node-input-summary");
722
+ appendMetaRow(dl, "outputMode", data.boundaries.outputMode, null);
723
+ appendMetaRow(
724
+ dl,
725
+ "firstProtocolLine",
726
+ data.boundaries.firstProtocolLine,
727
+ null,
728
+ );
729
+ section.appendChild(dl);
730
+ if (data.boundaries.outputContract) {
731
+ section.appendChild(el("h5", null, "outputContract"));
732
+ section.appendChild(renderMarkdown(data.boundaries.outputContract));
733
+ }
734
+ appendCodeList(section, "allowedPaths", data.boundaries.allowedPaths);
735
+ appendCodeList(section, "forbiddenPaths", data.boundaries.forbiddenPaths);
736
+ appendCodeList(section, "writeSet", data.boundaries.writeSet);
737
+ wrap.appendChild(section);
738
+ }
739
+
740
+ if (data.promptFingerprint?.exists) {
741
+ const section = el("section", "node-input-section");
742
+ section.appendChild(el("h4", null, "Assembled prompt 指纹"));
743
+ const dl = el("dl", "node-input-summary");
744
+ appendMetaRow(
745
+ dl,
746
+ "sha256",
747
+ data.promptFingerprint.sha256Prefix ?? "(parse failed)",
748
+ null,
749
+ );
750
+ appendMetaRow(
751
+ dl,
752
+ "lengthChars",
753
+ data.promptFingerprint.lengthChars,
754
+ null,
755
+ );
756
+ appendMetaRow(dl, "artifact", data.promptFingerprint.artifact, null);
757
+ section.appendChild(dl);
758
+ section.appendChild(
759
+ el("p", "muted", UI_TEXT.inputFingerprintNote),
760
+ );
761
+ wrap.appendChild(section);
762
+ }
763
+
764
+ if ((data.warnings ?? []).length > 0) {
765
+ const section = el("section", "node-input-section");
766
+ section.appendChild(el("h4", null, "警告"));
767
+ for (const warning of data.warnings) {
768
+ section.appendChild(el("p", "node-input-warning", String(warning)));
769
+ }
770
+ wrap.appendChild(section);
771
+ }
772
+
773
+ content.appendChild(wrap);
774
+ }
775
+
776
+ function unavailableMessage(reason) {
777
+ if (reason === "unsupported-dynamic-node") {
778
+ return UI_TEXT.inputDynamicUnsupported;
779
+ }
780
+ if (reason === "run-spec-unavailable") {
781
+ return UI_TEXT.inputRunSpecUnavailable;
782
+ }
783
+ if (reason === "task-not-found") {
784
+ return UI_TEXT.inputUnavailable;
785
+ }
786
+ return UI_TEXT.noInput;
787
+ }
788
+
789
+ async function renderDagNodeInput(content, dagRunId, node) {
790
+ const nodeId = node.nodeId;
791
+ const identity = makeDagNodeInputIdentity(dagRunId, nodeId);
792
+ uiState.dagNodeInputIdentity = identity;
793
+ uiState.dagNodeInputLoading = true;
794
+ uiState.dagNodeInputError = null;
795
+
796
+ clearNode(content);
797
+ const loading = el("p", "muted", UI_TEXT.inputLoading);
798
+ loading.setAttribute("role", "status");
799
+ loading.setAttribute("aria-live", "polite");
800
+ content.appendChild(loading);
801
+
802
+ const result = await fetchDagNodeInput(dagRunId, nodeId);
803
+ if (
804
+ uiState.dagNodeInputIdentity !== identity ||
805
+ uiState.selectedDagNodeId !== nodeId ||
806
+ uiState.currentDagRunId !== dagRunId ||
807
+ uiState.dagInspectorTab !== "input" ||
808
+ !content.isConnected ||
809
+ content.dataset.dagNodeId !== String(nodeId) ||
810
+ content.dataset.dagRunId !== String(dagRunId)
811
+ ) {
812
+ return;
813
+ }
814
+
815
+ uiState.dagNodeInputLoading = false;
816
+ clearNode(content);
817
+
818
+ if (!result.ok) {
819
+ const message =
820
+ result.body?.error ||
821
+ (result.status === 0
822
+ ? UI_TEXT.inputError
823
+ : `${UI_TEXT.inputError}(HTTP ${result.status})`);
824
+ uiState.dagNodeInputError = message;
825
+ uiState.dagNodeInputData = null;
826
+ const error = el("p", "node-input-error", message);
827
+ error.setAttribute("role", "alert");
828
+ content.appendChild(error);
829
+ return;
830
+ }
831
+
832
+ const data = result.body;
833
+ uiState.dagNodeInputData = data;
834
+ uiState.dagNodeInputError = null;
835
+ if (!data?.available) {
836
+ const empty = el(
837
+ "p",
838
+ "empty",
839
+ unavailableMessage(data?.availabilityReason),
840
+ );
841
+ empty.setAttribute("role", "status");
842
+ content.appendChild(empty);
843
+ if ((data?.warnings ?? []).length > 0) {
844
+ for (const warning of data.warnings) {
845
+ content.appendChild(el("p", "node-input-warning", String(warning)));
846
+ }
847
+ }
848
+ return;
849
+ }
850
+
851
+ renderDagNodeInputDetail(content, data);
852
+ }
853
+
533
854
  function fillDagInspectorContent(content, dagRunId, node) {
534
855
  const previousRenderedNode = content.dataset.dagNodeId ?? "";
535
- const nodeChanged = previousRenderedNode !== String(node.nodeId ?? "");
856
+ const previousRenderedRun = content.dataset.dagRunId ?? "";
857
+ const nodeChanged =
858
+ previousRenderedNode !== String(node.nodeId ?? "") ||
859
+ previousRenderedRun !== String(dagRunId ?? "");
536
860
  if (nodeChanged) {
537
861
  // Drop any open spec-evidence preview + cached list so a stale A detail
538
862
  // cannot survive into node B (AC-007 regression guard).
539
863
  uiState.specEvidenceDetail = null;
540
864
  uiState.specEvidenceListEvidence = null;
865
+ uiState.dagNodeInputData = null;
866
+ uiState.dagNodeInputError = null;
541
867
  }
542
- const activeTab = ["timeline", "spec-evidence"].includes(
868
+ const activeTab = ["input", "timeline", "spec-evidence"].includes(
543
869
  uiState.dagInspectorTab,
544
870
  )
545
871
  ? uiState.dagInspectorTab
@@ -548,12 +874,15 @@ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
548
874
  content.setAttribute("role", "tabpanel");
549
875
  content.setAttribute("aria-labelledby", `dag-inspector-tab-${activeTab}`);
550
876
  content.dataset.dagNodeId = String(node.nodeId ?? "");
877
+ content.dataset.dagRunId = String(dagRunId ?? "");
551
878
  // Drop timeline-only classes when switching tabs so output layout stays clean.
552
879
  content.className = "dag-inspector-content";
553
880
  if (activeTab === "spec-evidence") {
554
881
  void renderSpecEvidence(content, dagRunId, node.nodeId);
555
882
  } else if (activeTab === "timeline") {
556
883
  renderSessionTimeline(content, node.nodeId);
884
+ } else if (activeTab === "input") {
885
+ void renderDagNodeInput(content, dagRunId, node);
557
886
  } else if (!node.outputPreview && !node.errorPreview) {
558
887
  content.appendChild(el("p", "empty", UI_TEXT.noOutput));
559
888
  } else {
@@ -592,6 +921,7 @@ function buildDagInspectorHeader(dagRunId, node) {
592
921
  header.appendChild(controls);
593
922
 
594
923
  const tabDefs = [
924
+ ["input", "节点输入", "ri-login-box-line"],
595
925
  ["output", "节点输出", "ri-file-text-line"],
596
926
  ["timeline", "执行过程", "ri-route-line"],
597
927
  ["spec-evidence", "规范证据", "ri-book-open-line"],
@@ -660,6 +990,17 @@ function syncDagInspectorHeader(panel, dagRunId, node) {
660
990
  }
661
991
 
662
992
  function dagInspectorSignature(dagRunId, node) {
993
+ const inputRevision =
994
+ uiState.dagInspectorTab === "input"
995
+ ? [
996
+ uiState.dagNodeInputIdentity,
997
+ uiState.dagNodeInputLoading,
998
+ uiState.dagNodeInputError ?? "",
999
+ uiState.dagNodeInputData?.availabilityReason ?? "",
1000
+ uiState.dagNodeInputData?.promptFingerprint?.sha256Prefix ?? "",
1001
+ node.status ?? "",
1002
+ ]
1003
+ : null;
663
1004
  return JSON.stringify([
664
1005
  dagRunId,
665
1006
  node.nodeId,
@@ -667,6 +1008,7 @@ function dagInspectorSignature(dagRunId, node) {
667
1008
  node.outputPreview ?? "",
668
1009
  node.errorPreview ?? "",
669
1010
  uiState.dagInspectorTab === "timeline" ? uiState.sessionEventOffset : null,
1011
+ inputRevision,
670
1012
  ]);
671
1013
  }
672
1014
 
@@ -702,17 +1044,30 @@ export function renderDagInspector(dagRunId, node, initial = true) {
702
1044
  syncDagInspectorHeader(panel, dagRunId, node);
703
1045
  if (panel.dataset.contentSignature === nextContentSignature) return;
704
1046
  const previousNode = existingContent.dataset.dagNodeId ?? "";
705
- const nodeChanged = previousNode !== String(node.nodeId ?? "");
706
- const followLatest =
707
- nodeChanged ||
708
- computeFollowLatest(
709
- existingContent.scrollTop,
710
- existingContent.scrollHeight,
711
- existingContent.clientHeight,
712
- );
713
- const regionKey = `dag:${dagRunId}:${node.nodeId}:output`;
714
- uiState.dagNodeOutputViewportState = updateDagOutputViewportState(
715
- uiState.dagNodeOutputViewportState,
1047
+ const previousRun = existingContent.dataset.dagRunId ?? "";
1048
+ const nodeChanged =
1049
+ previousNode !== String(node.nodeId ?? "") ||
1050
+ previousRun !== String(dagRunId ?? "");
1051
+ const activeTab = ["input", "timeline", "spec-evidence"].includes(
1052
+ uiState.dagInspectorTab,
1053
+ )
1054
+ ? uiState.dagInspectorTab
1055
+ : "output";
1056
+ const isInputTab = activeTab === "input";
1057
+ const followLatest = isInputTab
1058
+ ? false
1059
+ : nodeChanged ||
1060
+ computeFollowLatest(
1061
+ existingContent.scrollTop,
1062
+ existingContent.scrollHeight,
1063
+ existingContent.clientHeight,
1064
+ );
1065
+ const regionKey = `dag:${dagRunId}:${node.nodeId}:${isInputTab ? "input" : "output"}`;
1066
+ const viewportUpdater = isInputTab
1067
+ ? "dagNodeInputViewportState"
1068
+ : "dagNodeOutputViewportState";
1069
+ uiState[viewportUpdater] = updateDagOutputViewportState(
1070
+ uiState[viewportUpdater],
716
1071
  regionKey,
717
1072
  existingContent.scrollLeft,
718
1073
  existingContent.scrollTop,
@@ -729,7 +1084,8 @@ export function renderDagInspector(dagRunId, node, initial = true) {
729
1084
  if (followLatest) {
730
1085
  existingContent.scrollTop = existingContent.scrollHeight;
731
1086
  } else {
732
- existingContent.scrollTop = uiState.dagNodeOutputViewportState.scrollTop;
1087
+ const saved = uiState[viewportUpdater];
1088
+ existingContent.scrollTop = nodeChanged ? 0 : (saved?.scrollTop ?? 0);
733
1089
  }
734
1090
  for (const [index, pre] of [
735
1091
  ...existingContent.querySelectorAll("pre"),
@@ -413,6 +413,18 @@ export async function validateBackendMarkdownCases(input) {
413
413
  if (!hasAssertableExpectedResult(expected)) {
414
414
  findings.push(`${testCase.id} has no structured expected result`);
415
415
  }
416
+ const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile);
417
+ const mappedScripts = extractMappedPytestScripts(testCase.body);
418
+ if (mappedScripts.length === 0) {
419
+ findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射 (expected ${expectedScript} from module ${path.basename(relativeFile)})`);
420
+ }
421
+ else {
422
+ for (const script of mappedScripts) {
423
+ if (script !== expectedScript) {
424
+ findings.push(`${testCase.id} automation mapping ${script} must equal module one-to-one path ${expectedScript} (from ${path.basename(relativeFile)})`);
425
+ }
426
+ }
427
+ }
416
428
  }
417
429
  }
418
430
  const missing = input.requiredRequirementIds.filter((id) => !coveredAc.has(id));
@@ -454,6 +466,27 @@ export async function validateBackendMarkdownCases(input) {
454
466
  function collectMarkdownCaseIds(markdown) {
455
467
  return splitCases(markdown).map((testCase) => testCase.id);
456
468
  }
469
+ /**
470
+ * Derive the stable module stem used for Markdown ↔ pytest one-to-one mapping.
471
+ * `testcase/md/<module>.md` → stem → `testcase/test_<stem>.py`.
472
+ * Example: `BE-HEALTH.md` / `be-health.md` / `order-api.md` → `be_health` / `order_api`.
473
+ */
474
+ export function normalizeBackendTestModuleStem(moduleFileName) {
475
+ const base = path.basename(moduleFileName).replace(/\.md$/i, "");
476
+ const stem = base
477
+ .toLowerCase()
478
+ .replace(/[^a-z0-9]+/g, "_")
479
+ .replace(/^_+|_+$/g, "")
480
+ .replace(/_+/g, "_");
481
+ if (!stem) {
482
+ throw new Error(`cannot derive backend-test module stem from: ${moduleFileName}`);
483
+ }
484
+ return stem;
485
+ }
486
+ /** Expected pytest script path for a Markdown module file (basename or relative path). */
487
+ export function expectedBackendTestPytestScriptForMarkdownModule(moduleFileName) {
488
+ return `testcase/test_${normalizeBackendTestModuleStem(moduleFileName)}.py`;
489
+ }
457
490
  function extractMappedPytestScripts(testCaseBody) {
458
491
  const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
459
492
  if (!automation.trim())
@@ -462,6 +495,15 @@ function extractMappedPytestScripts(testCaseBody) {
462
495
  .map((match) => match[1].replaceAll("\\", "/"))
463
496
  .filter((value) => !value.includes("..")));
464
497
  }
498
+ function isSafeBackendPytestScript(script, workspaceRoot) {
499
+ const absolute = path.resolve(workspaceRoot, script);
500
+ const relative = path.relative(workspaceRoot, absolute).replaceAll(path.sep, "/");
501
+ return (!relative.startsWith("..") &&
502
+ !path.isAbsolute(relative) &&
503
+ script.startsWith("testcase/") &&
504
+ !script.includes("..") &&
505
+ /^test_.*\.py$/i.test(path.basename(script)));
506
+ }
465
507
  function testFunctionRegion(input) {
466
508
  const functionLineStart = input.source.lastIndexOf("\n", input.functionIndex - 1) + 1;
467
509
  const functionLine = input.source.slice(functionLineStart, input.functionHeaderEnd);
@@ -610,26 +652,50 @@ export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
610
652
  for (const file of files) {
611
653
  if (path.basename(file).toLowerCase() === "readme.md")
612
654
  continue;
655
+ const relativeFile = path.relative(workspaceRoot, file).replaceAll(path.sep, "/");
656
+ const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile);
613
657
  const markdown = await readFile(file, "utf8");
658
+ const mappedFromModule = new Set();
614
659
  for (const testCase of splitCases(markdown)) {
615
- for (const script of extractMappedPytestScripts(testCase.body))
660
+ for (const script of extractMappedPytestScripts(testCase.body)) {
661
+ mappedFromModule.add(script);
662
+ }
663
+ }
664
+ // Prefer explicit mappings, but recover to the deterministic module stem path
665
+ // when the model wrote the correct one-to-one file while Markdown still names
666
+ // a drifted script (common: health.md maps test_health.py but writer emitted
667
+ // test_be_health.py for BE-HEALTH.md, or the reverse).
668
+ if (mappedFromModule.size === 0) {
669
+ if (await exists(path.resolve(workspaceRoot, expectedScript))) {
670
+ scripts.add(expectedScript);
671
+ }
672
+ continue;
673
+ }
674
+ for (const script of mappedFromModule) {
675
+ if (!isSafeBackendPytestScript(script, workspaceRoot)) {
676
+ throw new Error(`unsafe mapped pytest script: ${script}`);
677
+ }
678
+ if (await exists(path.resolve(workspaceRoot, script))) {
616
679
  scripts.add(script);
680
+ continue;
681
+ }
682
+ if (isSafeBackendPytestScript(expectedScript, workspaceRoot) &&
683
+ (await exists(path.resolve(workspaceRoot, expectedScript)))) {
684
+ scripts.add(expectedScript);
685
+ continue;
686
+ }
687
+ throw new Error(`mapped pytest script is missing: ${script} (module one-to-one path ${expectedScript} also missing)`);
617
688
  }
618
689
  }
619
690
  if (scripts.size === 0) {
620
691
  throw new Error("no pytest scripts are mapped by final Markdown cases");
621
692
  }
622
693
  const safeScripts = [];
623
- for (const script of unique(scripts)) {
624
- const absolute = path.resolve(workspaceRoot, script);
625
- const relative = path.relative(workspaceRoot, absolute);
626
- if (relative.startsWith("..") ||
627
- path.isAbsolute(relative) ||
628
- !script.startsWith("testcase/") ||
629
- !/^test_.*\.py$/i.test(path.basename(script))) {
694
+ for (const script of unique([...scripts]).sort()) {
695
+ if (!isSafeBackendPytestScript(script, workspaceRoot)) {
630
696
  throw new Error(`unsafe mapped pytest script: ${script}`);
631
697
  }
632
- if (!(await exists(absolute))) {
698
+ if (!(await exists(path.resolve(workspaceRoot, script)))) {
633
699
  throw new Error(`mapped pytest script is missing: ${script}`);
634
700
  }
635
701
  safeScripts.push(script);