forgexa-cli 1.47.1__tar.gz → 1.47.2__tar.gz

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 (28) hide show
  1. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/PKG-INFO +1 -1
  2. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/daemon.py +93 -3
  4. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/runtime_evidence.py +483 -42
  5. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/pyproject.toml +1 -1
  7. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/README.md +0 -0
  8. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/agent_core.py +0 -0
  11. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/autoupgrade.py +0 -0
  12. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/main.py +0 -0
  13. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  15. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  16. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
  17. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli.egg-info/requires.txt +0 -0
  18. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/forgexa_cli.egg-info/top_level.txt +0 -0
  19. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/setup.cfg +0 -0
  20. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_auth_and_runtime_commands.py +0 -0
  21. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_silent_install.py +0 -0
  28. {forgexa_cli-1.47.1 → forgexa_cli-1.47.2}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.47.1
3
+ Version: 1.47.2
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.47.1"
2
+ __version__ = "1.47.2"
@@ -1075,7 +1075,7 @@ except (ImportError, ModuleNotFoundError):
1075
1075
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1076
1076
  # Kept in sync with pyproject.toml version via bump-version.sh.
1077
1077
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1078
- DAEMON_VERSION = "1.47.1"
1078
+ DAEMON_VERSION = "1.47.2"
1079
1079
 
1080
1080
 
1081
1081
  def _detect_client_type() -> str:
@@ -10463,6 +10463,12 @@ class RuntimeDaemon:
10463
10463
  result,
10464
10464
  before_sha=node_before_sha,
10465
10465
  )
10466
+ await self._collect_runtime_acceptance_criteria_coverage_evidence(
10467
+ workspace_path,
10468
+ task,
10469
+ result,
10470
+ before_sha=node_before_sha,
10471
+ )
10466
10472
 
10467
10473
  # 6. Report completion (include actual agent used if different from requested)
10468
10474
  result.metrics["actual_agent"] = agent.agent_id
@@ -13292,11 +13298,15 @@ class RuntimeDaemon:
13292
13298
  "summary": "The committed source revision is unavailable for evidence collection.",
13293
13299
  }
13294
13300
  else:
13301
+ evidence_check = {
13302
+ **check,
13303
+ "output_dir": self._resolve_task_output_dir(task),
13304
+ }
13295
13305
  run = await self._run_runtime_evidence_in_worktree(
13296
13306
  workspace_path,
13297
13307
  task,
13298
13308
  after_sha,
13299
- check,
13309
+ evidence_check,
13300
13310
  runtime_evidence.run_test_coverage_evidence,
13301
13311
  )
13302
13312
 
@@ -13344,7 +13354,10 @@ class RuntimeDaemon:
13344
13354
  "artifact_refs": [artifact_ref],
13345
13355
  "runtime_evidence": evidence_identity,
13346
13356
  }
13347
- for key in ("test_pass_rate", "passed", "failed", "skipped", "errors", "total", "failed_tests"):
13357
+ for key in (
13358
+ "test_pass_rate", "passed", "failed", "skipped", "errors", "total",
13359
+ "failed_tests", "test_case_results", "exit_code",
13360
+ ):
13348
13361
  if key in run:
13349
13362
  test_observation[key] = run[key]
13350
13363
  if run.get("failure_reason"):
@@ -13723,6 +13736,83 @@ class RuntimeDaemon:
13723
13736
  observation["collection_failure_reason"] = run["failure_reason"]
13724
13737
  result.observations.append(observation)
13725
13738
 
13739
+ async def _collect_runtime_acceptance_criteria_coverage_evidence(
13740
+ self,
13741
+ workspace_path: Path,
13742
+ task: TaskInfo,
13743
+ result: TaskResult,
13744
+ *,
13745
+ before_sha: str,
13746
+ ) -> None:
13747
+ """Bind generated testing artifacts to the committed testing attempt."""
13748
+ if task.workspace_resolution != "cloud_worktree" or task.node_type != "testing":
13749
+ return
13750
+
13751
+ after_sha = str((result.git or {}).get("commit_sha") or "").strip()
13752
+ check = {"output_dir": self._resolve_task_output_dir(task)}
13753
+ if re.fullmatch(r"[0-9a-fA-F]{40}", after_sha) is None:
13754
+ run = {
13755
+ "status": "source_commit_unavailable",
13756
+ "collection_status": "unavailable",
13757
+ "failure_reason": "source_commit_unavailable",
13758
+ "coverage_links": [],
13759
+ "source_artifacts": [],
13760
+ "summary": "The committed source revision is unavailable for acceptance-criteria evidence.",
13761
+ }
13762
+ else:
13763
+ run = await self._run_runtime_evidence_in_worktree(
13764
+ workspace_path,
13765
+ task,
13766
+ after_sha,
13767
+ check,
13768
+ runtime_evidence.run_acceptance_criteria_coverage_evidence,
13769
+ )
13770
+ run.setdefault("coverage_links", [])
13771
+ run.setdefault("source_artifacts", [])
13772
+
13773
+ source_commit_range = {"after_sha": after_sha} if after_sha else {}
13774
+ if re.fullmatch(r"[0-9a-fA-F]{40}", before_sha or ""):
13775
+ source_commit_range["before_sha"] = before_sha
13776
+ report = {
13777
+ "version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
13778
+ "execution_attempt_id": task.execution_attempt_id,
13779
+ "check_kind": runtime_evidence.ACCEPTANCE_CRITERIA_COVERAGE_CHECK_KIND,
13780
+ "source_commit_range": source_commit_range,
13781
+ "result": run,
13782
+ }
13783
+ report_content = json.dumps(report, sort_keys=True, separators=(",", ":"))
13784
+ report_checksum = hashlib.sha256(report_content.encode("utf-8")).hexdigest()
13785
+ artifact_path = (
13786
+ f".forgexa/evidence/{task.task_id}/{after_sha[:12] or 'unavailable'}/"
13787
+ "acceptance-criteria.json"
13788
+ )
13789
+ artifact_ref = {"path": artifact_path, "checksum": report_checksum}
13790
+ result.artifacts.append({
13791
+ "path": artifact_path,
13792
+ "content": report_content,
13793
+ "content_hash": report_checksum,
13794
+ "type": "application/vnd.forgexa.gate-evidence+json",
13795
+ "system_generated": True,
13796
+ "artifact_role": "gate_evidence_report",
13797
+ })
13798
+ observation = {
13799
+ "type": runtime_evidence.ACCEPTANCE_CRITERIA_COVERAGE_CHECK_KIND,
13800
+ "collection_status": run.get("collection_status"),
13801
+ "summary": run.get("summary"),
13802
+ "artifact_refs": [artifact_ref],
13803
+ "runtime_evidence": {
13804
+ "version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
13805
+ "check_kind": runtime_evidence.ACCEPTANCE_CRITERIA_COVERAGE_CHECK_KIND,
13806
+ "execution_attempt_id": task.execution_attempt_id,
13807
+ "source_commit_range": source_commit_range,
13808
+ },
13809
+ "coverage_links": run.get("coverage_links", []),
13810
+ "source_artifacts": run.get("source_artifacts", []),
13811
+ }
13812
+ if run.get("failure_reason"):
13813
+ observation["collection_failure_reason"] = run["failure_reason"]
13814
+ result.observations.append(observation)
13815
+
13726
13816
  async def _collect_staged_diff_stats(self, cwd: Path) -> dict:
13727
13817
  """Collect staged diff stats for building a rich commit message."""
13728
13818
  status_map: dict[str, str] = {}
@@ -1,12 +1,16 @@
1
1
  """Portable planning and parsing for controlled runtime evidence checks."""
2
2
  from __future__ import annotations
3
3
 
4
+ import ast
4
5
  import asyncio
5
6
  import hashlib
6
7
  import json
8
+ import os
7
9
  import re
8
10
  import shlex
11
+ import tempfile
9
12
  import time
13
+ import xml.etree.ElementTree as ElementTree
10
14
  from pathlib import Path
11
15
  from typing import Any
12
16
 
@@ -16,11 +20,13 @@ TEST_COVERAGE_CHECK_KIND = "test_coverage"
16
20
  STATIC_CHECK_KIND = "static_check"
17
21
  TYPE_CHECK_KIND = "type_check"
18
22
  SECURITY_CHECK_KIND = "security_check"
23
+ ACCEPTANCE_CRITERIA_COVERAGE_CHECK_KIND = "acceptance_criteria_coverage"
19
24
  LOCAL_EVIDENCE_SUMMARY_VERSION = 1
20
25
  _DEFAULT_TIMEOUT_SECONDS = 600
21
26
  _MAX_FAILED_TESTS = 20
22
27
  _MAX_LINT_ISSUES = 50
23
28
  _MAX_OUTPUT_CHARS = 100_000
29
+ _MAX_TEST_CASE_RESULTS = 200
24
30
  _SHELL_METACHARACTERS = (";", "`", "$", ">", "<", "&", "|", "\n", "\r", "\x00")
25
31
  _SENSITIVE_ARGUMENT = re.compile(
26
32
  r"^-{1,2}(?:api[-_]?key|authorization|password|secret|token)(?:=|$)",
@@ -41,6 +47,12 @@ _BANDIT_SECURITY_CHECK_ARGV = (
41
47
  _SAFE_LOCAL_TEST_NAME = re.compile(r"^[A-Za-z0-9_./:-]+$")
42
48
  _SAFE_LOCAL_RUFF_RULE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,199}$")
43
49
  _SAFE_SECURITY_RULE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,199}$")
50
+ _SAFE_TEST_CASE_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,99}$")
51
+ _SAFE_TEST_INTENT_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,99}$")
52
+ _SAFE_ACCEPTANCE_REFERENCE = re.compile(r"^AC-(\d+)$", re.IGNORECASE)
53
+ _CANONICAL_TEST_CASE_ID = re.compile(r"^TC-(?:00[1-9]|0[1-9]\d|[1-9]\d{2})$", re.IGNORECASE)
54
+ _TEST_CASE_MARKER = re.compile(r"(?<![A-Za-z0-9_-])TC-(\d+)(?![A-Za-z0-9_-])", re.IGNORECASE)
55
+ _SAFE_PYTHON_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
44
56
  _MYPY_DIAGNOSTIC = re.compile(
45
57
  r"^(?P<file>[^:\n]+):(?P<line>[1-9]\d*)(?::(?P<column>[1-9]\d*))?: "
46
58
  r"error: (?P<message>.+?)(?:\s+\[(?P<code>[A-Za-z0-9_-]+)\])?\s*$"
@@ -676,6 +688,343 @@ def parse_bandit_json_output(output: object, exit_code: int) -> dict[str, Any]:
676
688
  return result
677
689
 
678
690
 
691
+ def parse_acceptance_criteria_coverage_artifacts(
692
+ test_cases_output: object,
693
+ coverage_matrix_output: object,
694
+ ) -> dict[str, Any]:
695
+ """Project bounded AC-to-test-case links without retaining raw artifacts."""
696
+ test_cases_text = _bounded_evidence_text(test_cases_output)
697
+ coverage_matrix_text = _bounded_evidence_text(coverage_matrix_output)
698
+ if test_cases_text is None or coverage_matrix_text is None:
699
+ return _acceptance_criteria_coverage_failure("acceptance_criteria_artifact_too_large")
700
+ try:
701
+ test_cases_document = json.loads(test_cases_text)
702
+ coverage_matrix_document = json.loads(coverage_matrix_text)
703
+ except json.JSONDecodeError:
704
+ return _acceptance_criteria_coverage_failure("acceptance_criteria_json_unparseable")
705
+ if (
706
+ not isinstance(test_cases_document, dict)
707
+ or not isinstance(test_cases_document.get("test_cases"), list)
708
+ or not isinstance(coverage_matrix_document, dict)
709
+ or not isinstance(coverage_matrix_document.get("acceptance_criteria"), list)
710
+ ):
711
+ return _acceptance_criteria_coverage_failure("acceptance_criteria_json_invalid_shape")
712
+
713
+ test_case_intents: dict[str, str] = {}
714
+ for raw_test_case in test_cases_document["test_cases"][:_MAX_LINT_ISSUES]:
715
+ if not isinstance(raw_test_case, dict):
716
+ return _acceptance_criteria_coverage_failure("test_case_invalid")
717
+ test_case_id = raw_test_case.get("id")
718
+ intent_id = raw_test_case.get("intent_id") or raw_test_case.get("test_intent_id")
719
+ if (
720
+ not isinstance(test_case_id, str)
721
+ or _SAFE_TEST_CASE_ID.fullmatch(test_case_id.strip()) is None
722
+ or not isinstance(intent_id, str)
723
+ or _SAFE_TEST_INTENT_ID.fullmatch(intent_id.strip()) is None
724
+ or test_case_id.strip() in test_case_intents
725
+ ):
726
+ return _acceptance_criteria_coverage_failure("test_case_invalid")
727
+ test_case_intents[test_case_id.strip()] = intent_id.strip()
728
+ if len(test_cases_document["test_cases"]) > _MAX_LINT_ISSUES:
729
+ return _acceptance_criteria_coverage_failure("test_case_limit_exceeded")
730
+
731
+ coverage_links: list[dict[str, Any]] = []
732
+ seen_acceptance_ids: set[str] = set()
733
+ for raw_criterion in coverage_matrix_document["acceptance_criteria"][:_MAX_LINT_ISSUES]:
734
+ if not isinstance(raw_criterion, dict):
735
+ return _acceptance_criteria_coverage_failure("coverage_matrix_invalid")
736
+ acceptance_id = _canonical_acceptance_criteria_reference(raw_criterion.get("id"))
737
+ status = raw_criterion.get("status")
738
+ covered_by = raw_criterion.get("covered_by")
739
+ if (
740
+ acceptance_id is None
741
+ or acceptance_id in seen_acceptance_ids
742
+ or not isinstance(status, str)
743
+ or status.strip().lower() != "covered"
744
+ or not isinstance(covered_by, list)
745
+ or not covered_by
746
+ ):
747
+ return _acceptance_criteria_coverage_failure("coverage_matrix_invalid")
748
+ test_case_ids: list[str] = []
749
+ for raw_test_case_id in covered_by:
750
+ if (
751
+ not isinstance(raw_test_case_id, str)
752
+ or raw_test_case_id.strip() not in test_case_intents
753
+ ):
754
+ return _acceptance_criteria_coverage_failure("coverage_matrix_test_case_unknown")
755
+ test_case_id = raw_test_case_id.strip()
756
+ if test_case_id not in test_case_ids:
757
+ test_case_ids.append(test_case_id)
758
+ seen_acceptance_ids.add(acceptance_id)
759
+ coverage_links.append({
760
+ "acceptance_criteria_ref": acceptance_id,
761
+ "test_case_ids": test_case_ids,
762
+ "test_intent_ids": [test_case_intents[test_case_id] for test_case_id in test_case_ids],
763
+ })
764
+ if len(coverage_matrix_document["acceptance_criteria"]) > _MAX_LINT_ISSUES:
765
+ return _acceptance_criteria_coverage_failure("coverage_matrix_limit_exceeded")
766
+ return {
767
+ "status": "completed",
768
+ "collection_status": "completed",
769
+ "coverage_links": coverage_links,
770
+ "summary": f"Validated {len(coverage_links)} acceptance-criteria coverage link(s).",
771
+ }
772
+
773
+
774
+ async def run_acceptance_criteria_coverage_evidence(
775
+ workspace_path: str | Path,
776
+ check: object,
777
+ ) -> dict[str, Any]:
778
+ """Read bounded testing artifacts from a committed detached worktree."""
779
+ output_dir = check.get("output_dir") if isinstance(check, dict) else None
780
+ paths = acceptance_criteria_coverage_artifact_paths(output_dir)
781
+ if paths is None:
782
+ return _acceptance_criteria_coverage_unavailable(
783
+ "testing_artifact_path_unavailable",
784
+ )
785
+ test_cases_path, coverage_matrix_path = paths
786
+ test_cases_content, test_cases_reason = _read_bounded_workspace_file(
787
+ workspace_path,
788
+ test_cases_path,
789
+ )
790
+ coverage_matrix_content, coverage_matrix_reason = _read_bounded_workspace_file(
791
+ workspace_path,
792
+ coverage_matrix_path,
793
+ )
794
+ if test_cases_content is None or coverage_matrix_content is None:
795
+ reason = (
796
+ "testing_artifact_too_large"
797
+ if "too_large" in {test_cases_reason, coverage_matrix_reason}
798
+ else "testing_artifact_unavailable"
799
+ )
800
+ return _acceptance_criteria_coverage_unavailable(reason)
801
+
802
+ report = parse_acceptance_criteria_coverage_artifacts(
803
+ test_cases_content,
804
+ coverage_matrix_content,
805
+ )
806
+ report["source_artifacts"] = [
807
+ {
808
+ "path": test_cases_path,
809
+ "sha256": hashlib.sha256(test_cases_content.encode("utf-8")).hexdigest(),
810
+ },
811
+ {
812
+ "path": coverage_matrix_path,
813
+ "sha256": hashlib.sha256(coverage_matrix_content.encode("utf-8")).hexdigest(),
814
+ },
815
+ ]
816
+ return report
817
+
818
+
819
+ def _temporary_junit_report_path() -> Path | None:
820
+ try:
821
+ descriptor, raw_path = tempfile.mkstemp(
822
+ prefix="forgexa-pytest-evidence-",
823
+ suffix=".xml",
824
+ )
825
+ except OSError:
826
+ return None
827
+ try:
828
+ os.close(descriptor)
829
+ except OSError:
830
+ try:
831
+ Path(raw_path).unlink(missing_ok=True)
832
+ except OSError:
833
+ pass
834
+ return None
835
+ return Path(raw_path)
836
+
837
+
838
+ def _test_case_results_from_junit(
839
+ workspace_path: str | Path,
840
+ output_dir: object,
841
+ junit_path: Path,
842
+ ) -> list[dict[str, str]]:
843
+ paths = acceptance_criteria_coverage_artifact_paths(output_dir)
844
+ if paths is None:
845
+ return []
846
+ test_cases_content, _ = _read_bounded_workspace_file(workspace_path, paths[0])
847
+ if test_cases_content is None:
848
+ return []
849
+ try:
850
+ test_cases_document = json.loads(test_cases_content)
851
+ except json.JSONDecodeError:
852
+ return []
853
+ declared_ids = _declared_test_case_ids(test_cases_document)
854
+ if not declared_ids:
855
+ return []
856
+ junit_content = _read_bounded_file_bytes(junit_path)
857
+ if junit_content is None:
858
+ return []
859
+ try:
860
+ junit_document = ElementTree.fromstring(junit_content)
861
+ except ElementTree.ParseError:
862
+ return []
863
+
864
+ workspace_root = Path(workspace_path).resolve()
865
+ outcomes: dict[str, str] = {}
866
+ marker_cache: dict[tuple[str, tuple[str, ...], str], set[str]] = {}
867
+ for testcase in junit_document.iter():
868
+ if _xml_tag_name(testcase.tag) != "testcase":
869
+ continue
870
+ classname = testcase.get("classname")
871
+ test_name = testcase.get("name")
872
+ source = _pytest_junit_source_file(workspace_root, classname)
873
+ function_name = _pytest_junit_function_name(test_name)
874
+ if source is None or function_name is None:
875
+ continue
876
+ source_path, class_path = source
877
+ cache_key = (str(source_path), class_path, function_name)
878
+ case_ids = marker_cache.get(cache_key)
879
+ if case_ids is None:
880
+ case_ids = _test_case_ids_in_python_test(
881
+ source_path,
882
+ class_path,
883
+ function_name,
884
+ set(declared_ids),
885
+ )
886
+ marker_cache[cache_key] = case_ids
887
+ outcome = _pytest_junit_outcome(testcase)
888
+ for case_id in case_ids:
889
+ outcomes[case_id] = _more_conservative_test_outcome(
890
+ outcomes.get(case_id),
891
+ outcome,
892
+ )
893
+ return [
894
+ {"id": case_id, "outcome": outcomes[case_id]}
895
+ for case_id in declared_ids
896
+ if case_id in outcomes
897
+ ]
898
+
899
+
900
+ def _declared_test_case_ids(document: object) -> list[str]:
901
+ if not isinstance(document, dict) or not isinstance(document.get("test_cases"), list):
902
+ return []
903
+ test_cases = document["test_cases"]
904
+ if len(test_cases) > _MAX_TEST_CASE_RESULTS:
905
+ return []
906
+ identifiers: list[str] = []
907
+ for test_case in test_cases:
908
+ identifier = test_case.get("id") if isinstance(test_case, dict) else None
909
+ if not isinstance(identifier, str) or _CANONICAL_TEST_CASE_ID.fullmatch(identifier) is None:
910
+ return []
911
+ identifier = identifier.upper()
912
+ if identifier in identifiers:
913
+ return []
914
+ identifiers.append(identifier)
915
+ return identifiers
916
+
917
+
918
+ def _pytest_junit_source_file(
919
+ workspace_root: Path,
920
+ classname: object,
921
+ ) -> tuple[Path, tuple[str, ...]] | None:
922
+ if not isinstance(classname, str) or not classname:
923
+ return None
924
+ components = classname.split(".")
925
+ if not all(_SAFE_PYTHON_IDENTIFIER.fullmatch(component) for component in components):
926
+ return None
927
+ for source_end in range(len(components), 0, -1):
928
+ relative_path = "/".join(components[:source_end]) + ".py"
929
+ if not _safe_workspace_path(relative_path):
930
+ return None
931
+ try:
932
+ source_path = (workspace_root / relative_path).resolve(strict=True)
933
+ source_path.relative_to(workspace_root)
934
+ except (OSError, ValueError):
935
+ continue
936
+ if source_path.is_file():
937
+ return source_path, tuple(components[source_end:])
938
+ return None
939
+
940
+
941
+ def _pytest_junit_function_name(value: object) -> str | None:
942
+ if not isinstance(value, str):
943
+ return None
944
+ name = value.split("[", 1)[0]
945
+ return name if _SAFE_PYTHON_IDENTIFIER.fullmatch(name) else None
946
+
947
+
948
+ def _test_case_ids_in_python_test(
949
+ source_path: Path,
950
+ class_path: tuple[str, ...],
951
+ function_name: str,
952
+ declared_ids: set[str],
953
+ ) -> set[str]:
954
+ source_content = _read_bounded_file_bytes(source_path)
955
+ if source_content is None:
956
+ return set()
957
+ try:
958
+ source_text = source_content.decode("utf-8")
959
+ except UnicodeDecodeError:
960
+ return set()
961
+ try:
962
+ document = ast.parse(source_text)
963
+ except SyntaxError:
964
+ return set()
965
+ lines = source_text.splitlines()
966
+ matched_ids: set[str] = set()
967
+
968
+ def visit(nodes: list[ast.stmt], parent_classes: tuple[str, ...]) -> None:
969
+ for child in nodes:
970
+ if isinstance(child, ast.ClassDef):
971
+ visit(child.body, (*parent_classes, child.name))
972
+ elif (
973
+ isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
974
+ and child.name == function_name
975
+ and parent_classes == class_path
976
+ ):
977
+ start_line = min(
978
+ [child.lineno, *(decorator.lineno for decorator in child.decorator_list)],
979
+ )
980
+ while start_line > 1 and lines[start_line - 2].lstrip().startswith("#"):
981
+ start_line -= 1
982
+ end_line = child.end_lineno or child.lineno
983
+ matched_ids.update(
984
+ _test_case_ids_from_marker_text(
985
+ "\n".join(lines[start_line - 1:end_line]),
986
+ declared_ids,
987
+ )
988
+ )
989
+
990
+ visit(document.body, ())
991
+ return matched_ids
992
+
993
+
994
+ def _test_case_ids_from_marker_text(value: str, declared_ids: set[str]) -> set[str]:
995
+ return {
996
+ f"TC-{int(match.group(1)):03d}"
997
+ for match in _TEST_CASE_MARKER.finditer(value)
998
+ if 1 <= int(match.group(1)) <= 999
999
+ and f"TC-{int(match.group(1)):03d}" in declared_ids
1000
+ }
1001
+
1002
+
1003
+ def _pytest_junit_outcome(testcase: ElementTree.Element) -> str:
1004
+ child_tags = {_xml_tag_name(child.tag) for child in testcase}
1005
+ if child_tags & {"failure", "error"}:
1006
+ return "failed"
1007
+ return "skipped" if "skipped" in child_tags else "passed"
1008
+
1009
+
1010
+ def _more_conservative_test_outcome(current: str | None, incoming: str) -> str:
1011
+ severity = {"skipped": 0, "passed": 1, "failed": 2}
1012
+ return incoming if current is None or severity[incoming] > severity[current] else current
1013
+
1014
+
1015
+ def _xml_tag_name(value: object) -> str:
1016
+ return value.rsplit("}", 1)[-1] if isinstance(value, str) else ""
1017
+
1018
+
1019
+ def _read_bounded_file_bytes(file_path: Path) -> bytes | None:
1020
+ try:
1021
+ with file_path.open("rb") as handle:
1022
+ content = handle.read(_MAX_OUTPUT_CHARS + 1)
1023
+ except OSError:
1024
+ return None
1025
+ return content if len(content) <= _MAX_OUTPUT_CHARS else None
1026
+
1027
+
679
1028
  async def run_test_coverage_evidence(
680
1029
  workspace_path: str | Path,
681
1030
  check: object,
@@ -694,50 +1043,70 @@ async def run_test_coverage_evidence(
694
1043
  timeout_seconds = (
695
1044
  timeout if isinstance(timeout, int) and 1 <= timeout <= 3600 else _DEFAULT_TIMEOUT_SECONDS
696
1045
  )
1046
+ junit_path = _temporary_junit_report_path()
1047
+ execution_argv = [
1048
+ *argv,
1049
+ f"--junitxml={junit_path}",
1050
+ ] if junit_path is not None else argv
697
1051
  started_at = time.monotonic()
698
1052
  try:
699
- process = await asyncio.create_subprocess_exec(
700
- *argv,
701
- cwd=str(Path(workspace_path)),
702
- stdout=asyncio.subprocess.PIPE,
703
- stderr=asyncio.subprocess.PIPE,
704
- )
705
- except FileNotFoundError:
706
- return {
707
- "status": "tool_unavailable",
708
- "collection_status": "unavailable",
709
- "failure_reason": "python_unavailable",
710
- "summary": "Python is unavailable on this runtime.",
711
- "duration_ms": int((time.monotonic() - started_at) * 1000),
712
- }
713
- except OSError:
714
- return {
715
- "status": "tool_unavailable",
716
- "collection_status": "unavailable",
717
- "failure_reason": "pytest_start_failed",
718
- "summary": "pytest could not be started on this runtime.",
719
- "duration_ms": int((time.monotonic() - started_at) * 1000),
720
- }
721
-
722
- try:
723
- stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_seconds)
724
- except asyncio.TimeoutError:
725
- process.kill()
726
- await process.communicate()
727
- return {
728
- "status": "timed_out",
729
- "collection_status": "unavailable",
730
- "failure_reason": "timeout",
731
- "summary": f"pytest evidence collection timed out after {timeout_seconds}s.",
732
- "duration_ms": int((time.monotonic() - started_at) * 1000),
733
- }
734
-
735
- output = (stdout + b"\n" + stderr).decode("utf-8", errors="replace")
736
- report = parse_pytest_coverage_output(output, process.returncode or 0)
737
- report["duration_ms"] = int((time.monotonic() - started_at) * 1000)
738
- report["tool"] = "pytest"
739
- report["command"] = command
740
- return report
1053
+ try:
1054
+ process = await asyncio.create_subprocess_exec(
1055
+ *execution_argv,
1056
+ cwd=str(Path(workspace_path)),
1057
+ stdout=asyncio.subprocess.PIPE,
1058
+ stderr=asyncio.subprocess.PIPE,
1059
+ )
1060
+ except FileNotFoundError:
1061
+ return {
1062
+ "status": "tool_unavailable",
1063
+ "collection_status": "unavailable",
1064
+ "failure_reason": "python_unavailable",
1065
+ "summary": "Python is unavailable on this runtime.",
1066
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
1067
+ }
1068
+ except OSError:
1069
+ return {
1070
+ "status": "tool_unavailable",
1071
+ "collection_status": "unavailable",
1072
+ "failure_reason": "pytest_start_failed",
1073
+ "summary": "pytest could not be started on this runtime.",
1074
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
1075
+ }
1076
+
1077
+ try:
1078
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_seconds)
1079
+ except asyncio.TimeoutError:
1080
+ process.kill()
1081
+ await process.communicate()
1082
+ return {
1083
+ "status": "timed_out",
1084
+ "collection_status": "unavailable",
1085
+ "failure_reason": "timeout",
1086
+ "summary": f"pytest evidence collection timed out after {timeout_seconds}s.",
1087
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
1088
+ }
1089
+
1090
+ output = (stdout + b"\n" + stderr).decode("utf-8", errors="replace")
1091
+ report = parse_pytest_coverage_output(output, process.returncode or 0)
1092
+ if junit_path is not None and report.get("collection_status") == "completed":
1093
+ test_case_results = _test_case_results_from_junit(
1094
+ workspace_path,
1095
+ check.get("output_dir") if isinstance(check, dict) else None,
1096
+ junit_path,
1097
+ )
1098
+ if test_case_results:
1099
+ report["test_case_results"] = test_case_results
1100
+ report["duration_ms"] = int((time.monotonic() - started_at) * 1000)
1101
+ report["tool"] = "pytest"
1102
+ report["command"] = command
1103
+ return report
1104
+ finally:
1105
+ if junit_path is not None:
1106
+ try:
1107
+ junit_path.unlink(missing_ok=True)
1108
+ except OSError:
1109
+ pass
741
1110
 
742
1111
 
743
1112
  async def run_static_check_evidence(
@@ -997,6 +1366,78 @@ def _safe_evidence_message(value: object) -> str:
997
1366
  return text[:1000]
998
1367
 
999
1368
 
1369
+ def _bounded_evidence_text(value: object) -> str | None:
1370
+ return value if isinstance(value, str) and len(value) <= _MAX_OUTPUT_CHARS else None
1371
+
1372
+
1373
+ def acceptance_criteria_coverage_artifact_paths(
1374
+ output_dir: object,
1375
+ ) -> tuple[str, str] | None:
1376
+ """Return the sole pair of testing-artifact paths allowed for P3 evidence."""
1377
+ if not isinstance(output_dir, str):
1378
+ return None
1379
+ normalized = output_dir.strip().replace("\\", "/")
1380
+ while normalized.startswith("./"):
1381
+ normalized = normalized[2:]
1382
+ if not _safe_workspace_path(normalized):
1383
+ return None
1384
+ return (
1385
+ f"{normalized}/test-cases.json",
1386
+ f"{normalized}/coverage-matrix.json",
1387
+ )
1388
+
1389
+
1390
+ def _read_bounded_workspace_file(
1391
+ workspace_path: str | Path,
1392
+ relative_path: str,
1393
+ ) -> tuple[str | None, str]:
1394
+ """Read one UTF-8 repository file without following a path outside root."""
1395
+ root = Path(workspace_path).resolve()
1396
+ try:
1397
+ file_path = (root / relative_path).resolve(strict=True)
1398
+ file_path.relative_to(root)
1399
+ if not file_path.is_file():
1400
+ return None, "unavailable"
1401
+ with file_path.open("rb") as handle:
1402
+ content = handle.read(_MAX_OUTPUT_CHARS + 1)
1403
+ except (OSError, ValueError):
1404
+ return None, "unavailable"
1405
+ if len(content) > _MAX_OUTPUT_CHARS:
1406
+ return None, "too_large"
1407
+ try:
1408
+ return content.decode("utf-8"), "completed"
1409
+ except UnicodeDecodeError:
1410
+ return None, "unavailable"
1411
+
1412
+
1413
+ def _canonical_acceptance_criteria_reference(value: object) -> str | None:
1414
+ if not isinstance(value, str):
1415
+ return None
1416
+ match = _SAFE_ACCEPTANCE_REFERENCE.fullmatch(value.strip())
1417
+ return f"AC-{int(match.group(1)):03d}" if match is not None else None
1418
+
1419
+
1420
+ def _acceptance_criteria_coverage_failure(reason: str) -> dict[str, Any]:
1421
+ return {
1422
+ "status": "parse_failed",
1423
+ "collection_status": "incomplete",
1424
+ "failure_reason": reason,
1425
+ "coverage_links": [],
1426
+ "summary": "Testing artifacts did not provide a usable acceptance-criteria coverage map.",
1427
+ }
1428
+
1429
+
1430
+ def _acceptance_criteria_coverage_unavailable(reason: str) -> dict[str, Any]:
1431
+ return {
1432
+ "status": "artifact_unavailable",
1433
+ "collection_status": "unavailable",
1434
+ "failure_reason": reason,
1435
+ "coverage_links": [],
1436
+ "source_artifacts": [],
1437
+ "summary": "Testing artifacts are unavailable for acceptance-criteria evidence.",
1438
+ }
1439
+
1440
+
1000
1441
  def _security_score(high: int, medium: int, low: int) -> float:
1001
1442
  if high > 0:
1002
1443
  return 0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.47.1
3
+ Version: 1.47.2
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.47.1"
3
+ version = "1.47.2"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes