forgexa-cli 1.47.1__tar.gz → 1.47.3__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.3}/PKG-INFO +1 -1
  2. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/daemon.py +95 -5
  4. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/main.py +47 -9
  5. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/runtime_evidence.py +497 -44
  6. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/pyproject.toml +1 -1
  8. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_auth_and_runtime_commands.py +96 -0
  9. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/README.md +0 -0
  10. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/_build_config.py +0 -0
  11. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/_local_bind.py +0 -0
  12. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/agent_core.py +0 -0
  13. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/autoupgrade.py +0 -0
  14. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/py.typed +0 -0
  15. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/setup.cfg +0 -0
  21. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_silent_install.py +0 -0
  28. {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/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.3
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.3"
@@ -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.3"
1079
1079
 
1080
1080
 
1081
1081
  def _detect_client_type() -> str:
@@ -7908,7 +7908,7 @@ class ServerConnection:
7908
7908
  "capabilities": {
7909
7909
  "platform": platform.platform(),
7910
7910
  "python_version": platform.python_version(),
7911
- "local_workspace_bindings": {"protocol_version": 2},
7911
+ "local_workspace_bindings": {"protocol_version": 3},
7912
7912
  },
7913
7913
  "server_url": self.server_url,
7914
7914
  "expires_in_days": 90,
@@ -8306,7 +8306,7 @@ class ServerConnection:
8306
8306
  "capabilities": {
8307
8307
  "platform": platform.platform(),
8308
8308
  "python_version": platform.python_version(),
8309
- "local_workspace_bindings": {"protocol_version": 2},
8309
+ "local_workspace_bindings": {"protocol_version": 3},
8310
8310
  "local_evidence": {"protocol_version": 1, "structured_summary": True},
8311
8311
  "local_evidence": {"protocol_version": 1, "structured_summary": True},
8312
8312
  },
@@ -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] = {}
@@ -1017,16 +1017,29 @@ def _version_is_newer(candidate: str, reference: str) -> bool:
1017
1017
  return False
1018
1018
 
1019
1019
 
1020
- def _run_update_check() -> None:
1021
- """Fetch the latest published CLI version and refresh the local cache."""
1020
+ def _latest_published_cli_version() -> str:
1021
+ """Return the current forgexa-cli version from the official PyPI API."""
1022
1022
  try:
1023
1023
  import httpx
1024
1024
 
1025
1025
  with httpx.Client(timeout=5) as client:
1026
- resp = client.get("https://pypi.org/pypi/forgexa-cli/json")
1027
- resp.raise_for_status()
1028
- latest = str(resp.json()["info"]["version"])
1029
- _write_update_cache(latest)
1026
+ response = client.get("https://pypi.org/pypi/forgexa-cli/json")
1027
+ response.raise_for_status()
1028
+ version = _normalize_target_version(str(response.json()["info"]["version"]))
1029
+ except Exception as exc:
1030
+ raise RuntimeError(
1031
+ "Could not determine the latest forgexa-cli release from PyPI. "
1032
+ "Check access to pypi.org or specify --target-version."
1033
+ ) from exc
1034
+ if version is None:
1035
+ raise RuntimeError("PyPI did not return a forgexa-cli version.")
1036
+ return version
1037
+
1038
+
1039
+ def _run_update_check() -> None:
1040
+ """Fetch the latest published CLI version and refresh the local cache."""
1041
+ try:
1042
+ _write_update_cache(_latest_published_cli_version())
1030
1043
  except Exception:
1031
1044
  pass # Network errors are silently ignored
1032
1045
 
@@ -1581,13 +1594,15 @@ def _run_windows_upgrade_worker(payload: str) -> None:
1581
1594
  parent_pid = int(data["parent_pid"])
1582
1595
  launcher_paths = data.get("launcher_paths", [])
1583
1596
  daemon_stopped = bool(data.get("daemon_stopped"))
1584
- target_version = str(data.get("target_version") or "latest")
1597
+ target_version = str(data.get("target_version") or "").strip()
1585
1598
  except (KeyError, TypeError, ValueError, json.JSONDecodeError):
1586
1599
  return
1587
1600
  if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
1588
1601
  return
1589
1602
  if not isinstance(launcher_paths, list) or not all(isinstance(path, str) for path in launcher_paths):
1590
1603
  return
1604
+ if not target_version:
1605
+ return
1591
1606
 
1592
1607
  try:
1593
1608
  log_path = _upgrade_log_path()
@@ -1608,6 +1623,13 @@ def _run_windows_upgrade_worker(payload: str) -> None:
1608
1623
  if result.returncode != 0:
1609
1624
  log_file.write(f"Upgrade failed (returncode={result.returncode}).\n")
1610
1625
  return
1626
+ installed_version = _installed_cli_version()
1627
+ if installed_version != target_version:
1628
+ log_file.write(
1629
+ f"Upgrade failed: expected {target_version}, "
1630
+ f"installed {installed_version or 'unknown'}.\n"
1631
+ )
1632
+ return
1611
1633
  log_file.write("Upgrade complete.\n")
1612
1634
  if daemon_stopped:
1613
1635
  restarted, server_url = _start_daemon_after_upgrade()
@@ -3446,12 +3468,14 @@ def cmd_version(args: argparse.Namespace) -> None:
3446
3468
  def cmd_upgrade(args: argparse.Namespace) -> None:
3447
3469
  try:
3448
3470
  target_version = _normalize_target_version(getattr(args, "target_version", None))
3471
+ if target_version is None:
3472
+ target_version = _latest_published_cli_version()
3449
3473
  plan = _build_upgrade_plan(target_version)
3450
3474
  except (RuntimeError, ValueError) as exc:
3451
3475
  print(str(exc), file=sys.stderr)
3452
3476
  sys.exit(1)
3453
3477
 
3454
- if target_version and target_version == plan.current_version:
3478
+ if target_version == plan.current_version:
3455
3479
  print(f"forgexa-cli {plan.current_version} is already installed.")
3456
3480
  return
3457
3481
 
@@ -3470,7 +3494,7 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
3470
3494
  print(f"Stopped local daemon (PID {daemon_pid}) before upgrade.")
3471
3495
 
3472
3496
  print(f"Current version : {plan.current_version}")
3473
- print(f"Target version : {target_version or 'latest'}")
3497
+ print(f"Target version : {target_version}")
3474
3498
  print(f"Upgrade mode : {plan.installer}")
3475
3499
  print(f"Running : {shlex.join(plan.command)}")
3476
3500
 
@@ -3494,6 +3518,20 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
3494
3518
  sys.exit(result.returncode or 1)
3495
3519
 
3496
3520
  installed_version = _installed_cli_version() or "unknown"
3521
+ if installed_version != target_version:
3522
+ print(
3523
+ f"Upgrade did not install the requested version {target_version}; "
3524
+ f"installed version is {installed_version}. "
3525
+ "The configured package index, proxy, or constraint may be stale.",
3526
+ file=sys.stderr,
3527
+ )
3528
+ if daemon_stopped:
3529
+ print(
3530
+ "The local daemon was stopped before the upgrade attempt. "
3531
+ "Restart it manually after resolving the package source issue.",
3532
+ file=sys.stderr,
3533
+ )
3534
+ sys.exit(1)
3497
3535
  print(f"Upgrade complete. Installed version: {installed_version}")
3498
3536
  if daemon_stopped:
3499
3537
  restarted, server_url = _start_daemon_after_upgrade()
@@ -1,26 +1,32 @@
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
 
13
-
14
17
  RUNTIME_EVIDENCE_PLAN_VERSION = 1
15
18
  TEST_COVERAGE_CHECK_KIND = "test_coverage"
16
19
  STATIC_CHECK_KIND = "static_check"
17
20
  TYPE_CHECK_KIND = "type_check"
18
21
  SECURITY_CHECK_KIND = "security_check"
22
+ ACCEPTANCE_CRITERIA_COVERAGE_CHECK_KIND = "acceptance_criteria_coverage"
19
23
  LOCAL_EVIDENCE_SUMMARY_VERSION = 1
20
24
  _DEFAULT_TIMEOUT_SECONDS = 600
21
25
  _MAX_FAILED_TESTS = 20
22
26
  _MAX_LINT_ISSUES = 50
27
+ _MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS = 200
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,19 @@ _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_]*$")
56
+ _COVERAGE_DECLARED_OUTCOMES = frozenset({
57
+ "covered",
58
+ "partial",
59
+ "uncovered",
60
+ "unverifiable",
61
+ "not_applicable",
62
+ })
44
63
  _MYPY_DIAGNOSTIC = re.compile(
45
64
  r"^(?P<file>[^:\n]+):(?P<line>[1-9]\d*)(?::(?P<column>[1-9]\d*))?: "
46
65
  r"error: (?P<message>.+?)(?:\s+\[(?P<code>[A-Za-z0-9_-]+)\])?\s*$"
@@ -676,6 +695,348 @@ def parse_bandit_json_output(output: object, exit_code: int) -> dict[str, Any]:
676
695
  return result
677
696
 
678
697
 
698
+ def parse_acceptance_criteria_coverage_artifacts(
699
+ test_cases_output: object,
700
+ coverage_matrix_output: object,
701
+ ) -> dict[str, Any]:
702
+ """Project bounded AC-to-test-case links without retaining raw artifacts."""
703
+ test_cases_text = _bounded_evidence_text(test_cases_output)
704
+ coverage_matrix_text = _bounded_evidence_text(coverage_matrix_output)
705
+ if test_cases_text is None or coverage_matrix_text is None:
706
+ return _acceptance_criteria_coverage_failure("acceptance_criteria_artifact_too_large")
707
+ try:
708
+ test_cases_document = json.loads(test_cases_text)
709
+ coverage_matrix_document = json.loads(coverage_matrix_text)
710
+ except json.JSONDecodeError:
711
+ return _acceptance_criteria_coverage_failure("acceptance_criteria_json_unparseable")
712
+ if (
713
+ not isinstance(test_cases_document, dict)
714
+ or not isinstance(test_cases_document.get("test_cases"), list)
715
+ or not isinstance(coverage_matrix_document, dict)
716
+ or not isinstance(coverage_matrix_document.get("acceptance_criteria"), list)
717
+ ):
718
+ return _acceptance_criteria_coverage_failure("acceptance_criteria_json_invalid_shape")
719
+
720
+ test_case_intents: dict[str, str] = {}
721
+ for raw_test_case in test_cases_document["test_cases"][:_MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS]:
722
+ if not isinstance(raw_test_case, dict):
723
+ return _acceptance_criteria_coverage_failure("test_case_invalid")
724
+ test_case_id = raw_test_case.get("id")
725
+ intent_id = raw_test_case.get("intent_id") or raw_test_case.get("test_intent_id")
726
+ if (
727
+ not isinstance(test_case_id, str)
728
+ or _SAFE_TEST_CASE_ID.fullmatch(test_case_id.strip()) is None
729
+ or not isinstance(intent_id, str)
730
+ or _SAFE_TEST_INTENT_ID.fullmatch(intent_id.strip()) is None
731
+ or test_case_id.strip() in test_case_intents
732
+ ):
733
+ return _acceptance_criteria_coverage_failure("test_case_invalid")
734
+ test_case_intents[test_case_id.strip()] = intent_id.strip()
735
+ if len(test_cases_document["test_cases"]) > _MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS:
736
+ return _acceptance_criteria_coverage_failure("test_case_limit_exceeded")
737
+
738
+ coverage_links: list[dict[str, Any]] = []
739
+ seen_acceptance_ids: set[str] = set()
740
+ for raw_criterion in coverage_matrix_document["acceptance_criteria"][:_MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS]:
741
+ if not isinstance(raw_criterion, dict):
742
+ return _acceptance_criteria_coverage_failure("coverage_matrix_invalid")
743
+ acceptance_id = _canonical_acceptance_criteria_reference(raw_criterion.get("id"))
744
+ raw_status = raw_criterion.get("status")
745
+ declared_outcome = raw_status.strip().lower() if isinstance(raw_status, str) else ""
746
+ covered_by = raw_criterion.get("covered_by", [])
747
+ if (
748
+ acceptance_id is None
749
+ or acceptance_id in seen_acceptance_ids
750
+ or not isinstance(covered_by, list)
751
+ or declared_outcome not in _COVERAGE_DECLARED_OUTCOMES
752
+ or (declared_outcome == "covered" and not covered_by)
753
+ or (
754
+ declared_outcome in {"uncovered", "unverifiable", "not_applicable"}
755
+ and covered_by
756
+ )
757
+ ):
758
+ return _acceptance_criteria_coverage_failure("coverage_matrix_invalid")
759
+ test_case_ids: list[str] = []
760
+ for raw_test_case_id in covered_by:
761
+ if (
762
+ not isinstance(raw_test_case_id, str)
763
+ or raw_test_case_id.strip() not in test_case_intents
764
+ ):
765
+ return _acceptance_criteria_coverage_failure("coverage_matrix_test_case_unknown")
766
+ test_case_id = raw_test_case_id.strip()
767
+ if test_case_id not in test_case_ids:
768
+ test_case_ids.append(test_case_id)
769
+ seen_acceptance_ids.add(acceptance_id)
770
+ coverage_links.append({
771
+ "acceptance_criteria_ref": acceptance_id,
772
+ "test_case_ids": test_case_ids,
773
+ "test_intent_ids": [test_case_intents[test_case_id] for test_case_id in test_case_ids],
774
+ "declared_outcome": declared_outcome,
775
+ })
776
+ if len(coverage_matrix_document["acceptance_criteria"]) > _MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS:
777
+ return _acceptance_criteria_coverage_failure("coverage_matrix_limit_exceeded")
778
+ return {
779
+ "status": "completed",
780
+ "collection_status": "completed",
781
+ "coverage_links": coverage_links,
782
+ "summary": f"Validated {len(coverage_links)} acceptance-criteria coverage link(s).",
783
+ }
784
+
785
+
786
+ async def run_acceptance_criteria_coverage_evidence(
787
+ workspace_path: str | Path,
788
+ check: object,
789
+ ) -> dict[str, Any]:
790
+ """Read bounded testing artifacts from a committed detached worktree."""
791
+ output_dir = check.get("output_dir") if isinstance(check, dict) else None
792
+ paths = acceptance_criteria_coverage_artifact_paths(output_dir)
793
+ if paths is None:
794
+ return _acceptance_criteria_coverage_unavailable(
795
+ "testing_artifact_path_unavailable",
796
+ )
797
+ test_cases_path, coverage_matrix_path = paths
798
+ test_cases_content, test_cases_reason = _read_bounded_workspace_file(
799
+ workspace_path,
800
+ test_cases_path,
801
+ )
802
+ coverage_matrix_content, coverage_matrix_reason = _read_bounded_workspace_file(
803
+ workspace_path,
804
+ coverage_matrix_path,
805
+ )
806
+ if test_cases_content is None or coverage_matrix_content is None:
807
+ reason = (
808
+ "testing_artifact_too_large"
809
+ if "too_large" in {test_cases_reason, coverage_matrix_reason}
810
+ else "testing_artifact_unavailable"
811
+ )
812
+ return _acceptance_criteria_coverage_unavailable(reason)
813
+
814
+ report = parse_acceptance_criteria_coverage_artifacts(
815
+ test_cases_content,
816
+ coverage_matrix_content,
817
+ )
818
+ report["source_artifacts"] = [
819
+ {
820
+ "path": test_cases_path,
821
+ "sha256": hashlib.sha256(test_cases_content.encode("utf-8")).hexdigest(),
822
+ },
823
+ {
824
+ "path": coverage_matrix_path,
825
+ "sha256": hashlib.sha256(coverage_matrix_content.encode("utf-8")).hexdigest(),
826
+ },
827
+ ]
828
+ return report
829
+
830
+
831
+ def _temporary_junit_report_path() -> Path | None:
832
+ try:
833
+ descriptor, raw_path = tempfile.mkstemp(
834
+ prefix="forgexa-pytest-evidence-",
835
+ suffix=".xml",
836
+ )
837
+ except OSError:
838
+ return None
839
+ try:
840
+ os.close(descriptor)
841
+ except OSError:
842
+ try:
843
+ Path(raw_path).unlink(missing_ok=True)
844
+ except OSError:
845
+ pass
846
+ return None
847
+ return Path(raw_path)
848
+
849
+
850
+ def _test_case_results_from_junit(
851
+ workspace_path: str | Path,
852
+ output_dir: object,
853
+ junit_path: Path,
854
+ ) -> list[dict[str, str]]:
855
+ paths = acceptance_criteria_coverage_artifact_paths(output_dir)
856
+ if paths is None:
857
+ return []
858
+ test_cases_content, _ = _read_bounded_workspace_file(workspace_path, paths[0])
859
+ if test_cases_content is None:
860
+ return []
861
+ try:
862
+ test_cases_document = json.loads(test_cases_content)
863
+ except json.JSONDecodeError:
864
+ return []
865
+ declared_ids = _declared_test_case_ids(test_cases_document)
866
+ if not declared_ids:
867
+ return []
868
+ junit_content = _read_bounded_file_bytes(junit_path)
869
+ if junit_content is None:
870
+ return []
871
+ try:
872
+ junit_document = ElementTree.fromstring(junit_content)
873
+ except ElementTree.ParseError:
874
+ return []
875
+
876
+ workspace_root = Path(workspace_path).resolve()
877
+ outcomes: dict[str, str] = {}
878
+ marker_cache: dict[tuple[str, tuple[str, ...], str], set[str]] = {}
879
+ for testcase in junit_document.iter():
880
+ if _xml_tag_name(testcase.tag) != "testcase":
881
+ continue
882
+ classname = testcase.get("classname")
883
+ test_name = testcase.get("name")
884
+ source = _pytest_junit_source_file(workspace_root, classname)
885
+ function_name = _pytest_junit_function_name(test_name)
886
+ if source is None or function_name is None:
887
+ continue
888
+ source_path, class_path = source
889
+ cache_key = (str(source_path), class_path, function_name)
890
+ case_ids = marker_cache.get(cache_key)
891
+ if case_ids is None:
892
+ case_ids = _test_case_ids_in_python_test(
893
+ source_path,
894
+ class_path,
895
+ function_name,
896
+ set(declared_ids),
897
+ )
898
+ marker_cache[cache_key] = case_ids
899
+ outcome = _pytest_junit_outcome(testcase)
900
+ for case_id in case_ids:
901
+ outcomes[case_id] = _more_conservative_test_outcome(
902
+ outcomes.get(case_id),
903
+ outcome,
904
+ )
905
+ return [
906
+ {"id": case_id, "outcome": outcomes[case_id]}
907
+ for case_id in declared_ids
908
+ if case_id in outcomes
909
+ ]
910
+
911
+
912
+ def _declared_test_case_ids(document: object) -> list[str]:
913
+ if not isinstance(document, dict) or not isinstance(document.get("test_cases"), list):
914
+ return []
915
+ test_cases = document["test_cases"]
916
+ if len(test_cases) > _MAX_TEST_CASE_RESULTS:
917
+ return []
918
+ identifiers: list[str] = []
919
+ for test_case in test_cases:
920
+ identifier = test_case.get("id") if isinstance(test_case, dict) else None
921
+ if not isinstance(identifier, str) or _CANONICAL_TEST_CASE_ID.fullmatch(identifier) is None:
922
+ return []
923
+ identifier = identifier.upper()
924
+ if identifier in identifiers:
925
+ return []
926
+ identifiers.append(identifier)
927
+ return identifiers
928
+
929
+
930
+ def _pytest_junit_source_file(
931
+ workspace_root: Path,
932
+ classname: object,
933
+ ) -> tuple[Path, tuple[str, ...]] | None:
934
+ if not isinstance(classname, str) or not classname:
935
+ return None
936
+ components = classname.split(".")
937
+ if not all(_SAFE_PYTHON_IDENTIFIER.fullmatch(component) for component in components):
938
+ return None
939
+ for source_end in range(len(components), 0, -1):
940
+ relative_path = "/".join(components[:source_end]) + ".py"
941
+ if not _safe_workspace_path(relative_path):
942
+ return None
943
+ try:
944
+ source_path = (workspace_root / relative_path).resolve(strict=True)
945
+ source_path.relative_to(workspace_root)
946
+ except (OSError, ValueError):
947
+ continue
948
+ if source_path.is_file():
949
+ return source_path, tuple(components[source_end:])
950
+ return None
951
+
952
+
953
+ def _pytest_junit_function_name(value: object) -> str | None:
954
+ if not isinstance(value, str):
955
+ return None
956
+ name = value.split("[", 1)[0]
957
+ return name if _SAFE_PYTHON_IDENTIFIER.fullmatch(name) else None
958
+
959
+
960
+ def _test_case_ids_in_python_test(
961
+ source_path: Path,
962
+ class_path: tuple[str, ...],
963
+ function_name: str,
964
+ declared_ids: set[str],
965
+ ) -> set[str]:
966
+ source_content = _read_bounded_file_bytes(source_path)
967
+ if source_content is None:
968
+ return set()
969
+ try:
970
+ source_text = source_content.decode("utf-8")
971
+ except UnicodeDecodeError:
972
+ return set()
973
+ try:
974
+ document = ast.parse(source_text)
975
+ except SyntaxError:
976
+ return set()
977
+ lines = source_text.splitlines()
978
+ matched_ids: set[str] = set()
979
+
980
+ def visit(nodes: list[ast.stmt], parent_classes: tuple[str, ...]) -> None:
981
+ for child in nodes:
982
+ if isinstance(child, ast.ClassDef):
983
+ visit(child.body, (*parent_classes, child.name))
984
+ elif (
985
+ isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
986
+ and child.name == function_name
987
+ and parent_classes == class_path
988
+ ):
989
+ start_line = min(
990
+ [child.lineno, *(decorator.lineno for decorator in child.decorator_list)],
991
+ )
992
+ while start_line > 1 and lines[start_line - 2].lstrip().startswith("#"):
993
+ start_line -= 1
994
+ end_line = child.end_lineno or child.lineno
995
+ matched_ids.update(
996
+ _test_case_ids_from_marker_text(
997
+ "\n".join(lines[start_line - 1:end_line]),
998
+ declared_ids,
999
+ )
1000
+ )
1001
+
1002
+ visit(document.body, ())
1003
+ return matched_ids
1004
+
1005
+
1006
+ def _test_case_ids_from_marker_text(value: str, declared_ids: set[str]) -> set[str]:
1007
+ return {
1008
+ f"TC-{int(match.group(1)):03d}"
1009
+ for match in _TEST_CASE_MARKER.finditer(value)
1010
+ if 1 <= int(match.group(1)) <= 999
1011
+ and f"TC-{int(match.group(1)):03d}" in declared_ids
1012
+ }
1013
+
1014
+
1015
+ def _pytest_junit_outcome(testcase: ElementTree.Element) -> str:
1016
+ child_tags = {_xml_tag_name(child.tag) for child in testcase}
1017
+ if child_tags & {"failure", "error"}:
1018
+ return "failed"
1019
+ return "skipped" if "skipped" in child_tags else "passed"
1020
+
1021
+
1022
+ def _more_conservative_test_outcome(current: str | None, incoming: str) -> str:
1023
+ severity = {"skipped": 0, "passed": 1, "failed": 2}
1024
+ return incoming if current is None or severity[incoming] > severity[current] else current
1025
+
1026
+
1027
+ def _xml_tag_name(value: object) -> str:
1028
+ return value.rsplit("}", 1)[-1] if isinstance(value, str) else ""
1029
+
1030
+
1031
+ def _read_bounded_file_bytes(file_path: Path) -> bytes | None:
1032
+ try:
1033
+ with file_path.open("rb") as handle:
1034
+ content = handle.read(_MAX_OUTPUT_CHARS + 1)
1035
+ except OSError:
1036
+ return None
1037
+ return content if len(content) <= _MAX_OUTPUT_CHARS else None
1038
+
1039
+
679
1040
  async def run_test_coverage_evidence(
680
1041
  workspace_path: str | Path,
681
1042
  check: object,
@@ -694,50 +1055,70 @@ async def run_test_coverage_evidence(
694
1055
  timeout_seconds = (
695
1056
  timeout if isinstance(timeout, int) and 1 <= timeout <= 3600 else _DEFAULT_TIMEOUT_SECONDS
696
1057
  )
1058
+ junit_path = _temporary_junit_report_path()
1059
+ execution_argv = [
1060
+ *argv,
1061
+ f"--junitxml={junit_path}",
1062
+ ] if junit_path is not None else argv
697
1063
  started_at = time.monotonic()
698
1064
  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
1065
+ try:
1066
+ process = await asyncio.create_subprocess_exec(
1067
+ *execution_argv,
1068
+ cwd=str(Path(workspace_path)),
1069
+ stdout=asyncio.subprocess.PIPE,
1070
+ stderr=asyncio.subprocess.PIPE,
1071
+ )
1072
+ except FileNotFoundError:
1073
+ return {
1074
+ "status": "tool_unavailable",
1075
+ "collection_status": "unavailable",
1076
+ "failure_reason": "python_unavailable",
1077
+ "summary": "Python is unavailable on this runtime.",
1078
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
1079
+ }
1080
+ except OSError:
1081
+ return {
1082
+ "status": "tool_unavailable",
1083
+ "collection_status": "unavailable",
1084
+ "failure_reason": "pytest_start_failed",
1085
+ "summary": "pytest could not be started on this runtime.",
1086
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
1087
+ }
1088
+
1089
+ try:
1090
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_seconds)
1091
+ except asyncio.TimeoutError:
1092
+ process.kill()
1093
+ await process.communicate()
1094
+ return {
1095
+ "status": "timed_out",
1096
+ "collection_status": "unavailable",
1097
+ "failure_reason": "timeout",
1098
+ "summary": f"pytest evidence collection timed out after {timeout_seconds}s.",
1099
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
1100
+ }
1101
+
1102
+ output = (stdout + b"\n" + stderr).decode("utf-8", errors="replace")
1103
+ report = parse_pytest_coverage_output(output, process.returncode or 0)
1104
+ if junit_path is not None and report.get("collection_status") == "completed":
1105
+ test_case_results = _test_case_results_from_junit(
1106
+ workspace_path,
1107
+ check.get("output_dir") if isinstance(check, dict) else None,
1108
+ junit_path,
1109
+ )
1110
+ if test_case_results:
1111
+ report["test_case_results"] = test_case_results
1112
+ report["duration_ms"] = int((time.monotonic() - started_at) * 1000)
1113
+ report["tool"] = "pytest"
1114
+ report["command"] = command
1115
+ return report
1116
+ finally:
1117
+ if junit_path is not None:
1118
+ try:
1119
+ junit_path.unlink(missing_ok=True)
1120
+ except OSError:
1121
+ pass
741
1122
 
742
1123
 
743
1124
  async def run_static_check_evidence(
@@ -997,6 +1378,78 @@ def _safe_evidence_message(value: object) -> str:
997
1378
  return text[:1000]
998
1379
 
999
1380
 
1381
+ def _bounded_evidence_text(value: object) -> str | None:
1382
+ return value if isinstance(value, str) and len(value) <= _MAX_OUTPUT_CHARS else None
1383
+
1384
+
1385
+ def acceptance_criteria_coverage_artifact_paths(
1386
+ output_dir: object,
1387
+ ) -> tuple[str, str] | None:
1388
+ """Return the sole pair of testing-artifact paths allowed for P3 evidence."""
1389
+ if not isinstance(output_dir, str):
1390
+ return None
1391
+ normalized = output_dir.strip().replace("\\", "/")
1392
+ while normalized.startswith("./"):
1393
+ normalized = normalized[2:]
1394
+ if not _safe_workspace_path(normalized):
1395
+ return None
1396
+ return (
1397
+ f"{normalized}/test-cases.json",
1398
+ f"{normalized}/coverage-matrix.json",
1399
+ )
1400
+
1401
+
1402
+ def _read_bounded_workspace_file(
1403
+ workspace_path: str | Path,
1404
+ relative_path: str,
1405
+ ) -> tuple[str | None, str]:
1406
+ """Read one UTF-8 repository file without following a path outside root."""
1407
+ root = Path(workspace_path).resolve()
1408
+ try:
1409
+ file_path = (root / relative_path).resolve(strict=True)
1410
+ file_path.relative_to(root)
1411
+ if not file_path.is_file():
1412
+ return None, "unavailable"
1413
+ with file_path.open("rb") as handle:
1414
+ content = handle.read(_MAX_OUTPUT_CHARS + 1)
1415
+ except (OSError, ValueError):
1416
+ return None, "unavailable"
1417
+ if len(content) > _MAX_OUTPUT_CHARS:
1418
+ return None, "too_large"
1419
+ try:
1420
+ return content.decode("utf-8"), "completed"
1421
+ except UnicodeDecodeError:
1422
+ return None, "unavailable"
1423
+
1424
+
1425
+ def _canonical_acceptance_criteria_reference(value: object) -> str | None:
1426
+ if not isinstance(value, str):
1427
+ return None
1428
+ match = _SAFE_ACCEPTANCE_REFERENCE.fullmatch(value.strip())
1429
+ return f"AC-{int(match.group(1)):03d}" if match is not None else None
1430
+
1431
+
1432
+ def _acceptance_criteria_coverage_failure(reason: str) -> dict[str, Any]:
1433
+ return {
1434
+ "status": "parse_failed",
1435
+ "collection_status": "incomplete",
1436
+ "failure_reason": reason,
1437
+ "coverage_links": [],
1438
+ "summary": "Testing artifacts did not provide a usable acceptance-criteria coverage map.",
1439
+ }
1440
+
1441
+
1442
+ def _acceptance_criteria_coverage_unavailable(reason: str) -> dict[str, Any]:
1443
+ return {
1444
+ "status": "artifact_unavailable",
1445
+ "collection_status": "unavailable",
1446
+ "failure_reason": reason,
1447
+ "coverage_links": [],
1448
+ "source_artifacts": [],
1449
+ "summary": "Testing artifacts are unavailable for acceptance-criteria evidence.",
1450
+ }
1451
+
1452
+
1000
1453
  def _security_score(high: int, medium: int, low: int) -> float:
1001
1454
  if high > 0:
1002
1455
  return 0.0
@@ -1008,4 +1461,4 @@ def _security_score(high: int, medium: int, low: int) -> float:
1008
1461
  return 0.8
1009
1462
  if low > 0:
1010
1463
  return max(0.85, 1.0 - low * 0.03)
1011
- return 1.0
1464
+ return 1.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.3
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.3"
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"
@@ -730,6 +730,66 @@ def test_cmd_upgrade_runs_planned_command(monkeypatch: pytest.MonkeyPatch) -> No
730
730
  assert "Upgrade complete. Installed version: 1.12.3" in output
731
731
 
732
732
 
733
+ def test_cmd_upgrade_resolves_latest_to_an_explicit_pypi_version(
734
+ monkeypatch: pytest.MonkeyPatch,
735
+ ) -> None:
736
+ plan = main.UpgradePlan(
737
+ installer="pipx",
738
+ command=["pipx", "install", "--force", "forgexa-cli==1.12.3"],
739
+ current_version="1.12.2",
740
+ target_version="1.12.3",
741
+ )
742
+ calls: list[list[str]] = []
743
+
744
+ def build_upgrade_plan(target_version: str | None) -> main.UpgradePlan:
745
+ assert target_version == "1.12.3"
746
+ return plan
747
+
748
+ monkeypatch.setattr(main, "_latest_published_cli_version", lambda: "1.12.3")
749
+ monkeypatch.setattr(main, "_build_upgrade_plan", build_upgrade_plan)
750
+ monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (False, None))
751
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.3")
752
+ monkeypatch.setattr(main, "_start_daemon_after_upgrade", lambda: (False, None))
753
+ monkeypatch.setattr(
754
+ main.subprocess,
755
+ "run",
756
+ lambda command: calls.append(command) or SimpleNamespace(returncode=0),
757
+ )
758
+
759
+ buffer = io.StringIO()
760
+ with contextlib.redirect_stdout(buffer):
761
+ main.cmd_upgrade(SimpleNamespace(target_version=None))
762
+
763
+ assert calls == [["pipx", "install", "--force", "forgexa-cli==1.12.3"]]
764
+ assert "Target version : 1.12.3" in buffer.getvalue()
765
+
766
+
767
+ def test_cmd_upgrade_rejects_success_when_target_version_is_not_installed(
768
+ monkeypatch: pytest.MonkeyPatch,
769
+ capsys: pytest.CaptureFixture[str],
770
+ ) -> None:
771
+ plan = main.UpgradePlan(
772
+ installer="pipx",
773
+ command=["pipx", "install", "--force", "forgexa-cli==1.12.3"],
774
+ current_version="1.12.2",
775
+ target_version="1.12.3",
776
+ )
777
+ restart_calls: list[bool] = []
778
+
779
+ monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: plan)
780
+ monkeypatch.setattr(main, "_normalize_target_version", lambda value: value)
781
+ monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (True, 4242))
782
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.2")
783
+ monkeypatch.setattr(main, "_start_daemon_after_upgrade", lambda: restart_calls.append(True))
784
+ monkeypatch.setattr(main.subprocess, "run", lambda command: SimpleNamespace(returncode=0))
785
+
786
+ with pytest.raises(SystemExit, match="1"):
787
+ main.cmd_upgrade(SimpleNamespace(target_version="1.12.3"))
788
+
789
+ assert restart_calls == []
790
+ assert "did not install the requested version 1.12.3" in capsys.readouterr().err
791
+
792
+
733
793
  def test_cmd_upgrade_defers_windows_install_until_cli_exits(
734
794
  monkeypatch: pytest.MonkeyPatch,
735
795
  ) -> None:
@@ -812,6 +872,7 @@ def test_windows_upgrade_worker_waits_before_running_pip(
812
872
  "run",
813
873
  lambda command, **kwargs: events.append("run") or SimpleNamespace(returncode=0),
814
874
  )
875
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.3")
815
876
 
816
877
  main._run_windows_upgrade_worker(
817
878
  json.dumps(
@@ -838,6 +899,7 @@ def test_windows_upgrade_worker_restarts_stopped_daemon_after_success(
838
899
  monkeypatch.setattr(main, "_upgrade_log_path", lambda: log_path)
839
900
  monkeypatch.setattr(main, "_wait_for_windows_upgrade_release", lambda *args: True)
840
901
  monkeypatch.setattr(main.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0))
902
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.3")
841
903
  monkeypatch.setattr(
842
904
  main,
843
905
  "_start_daemon_after_upgrade",
@@ -860,6 +922,38 @@ def test_windows_upgrade_worker_restarts_stopped_daemon_after_success(
860
922
  assert "Daemon restarted in background (server=https://api.example.com)." in log_path.read_text()
861
923
 
862
924
 
925
+ def test_windows_upgrade_worker_does_not_restart_when_version_is_not_installed(
926
+ tmp_path: Path,
927
+ monkeypatch: pytest.MonkeyPatch,
928
+ ) -> None:
929
+ log_path = tmp_path / "upgrade.log"
930
+ restart_calls: list[bool] = []
931
+ monkeypatch.setattr(main, "_upgrade_log_path", lambda: log_path)
932
+ monkeypatch.setattr(main, "_wait_for_windows_upgrade_release", lambda *args: True)
933
+ monkeypatch.setattr(main.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0))
934
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.2")
935
+ monkeypatch.setattr(
936
+ main,
937
+ "_start_daemon_after_upgrade",
938
+ lambda: restart_calls.append(True) or (True, "https://api.example.com"),
939
+ )
940
+
941
+ main._run_windows_upgrade_worker(
942
+ json.dumps(
943
+ {
944
+ "command": ["python", "-m", "pip", "install", "--upgrade", "forgexa-cli"],
945
+ "parent_pid": 4242,
946
+ "launcher_paths": [],
947
+ "daemon_stopped": True,
948
+ "target_version": "1.12.3",
949
+ }
950
+ )
951
+ )
952
+
953
+ assert restart_calls == []
954
+ assert "Upgrade failed: expected 1.12.3, installed 1.12.2." in log_path.read_text()
955
+
956
+
863
957
  def test_cmd_upgrade_restarts_background_daemon_after_success(
864
958
  monkeypatch: pytest.MonkeyPatch,
865
959
  ) -> None:
@@ -873,6 +967,7 @@ def test_cmd_upgrade_restarts_background_daemon_after_success(
873
967
 
874
968
  monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: plan)
875
969
  monkeypatch.setattr(main, "_normalize_target_version", lambda value: value)
970
+ monkeypatch.setattr(main, "_latest_published_cli_version", lambda: "1.16.4")
876
971
  monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (True, 4242))
877
972
  monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.16.4")
878
973
  monkeypatch.setattr(main.subprocess, "run", lambda cmd: SimpleNamespace(returncode=0))
@@ -905,6 +1000,7 @@ def test_cmd_upgrade_keeps_manual_restart_hint_when_auto_restart_skips(
905
1000
 
906
1001
  monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: plan)
907
1002
  monkeypatch.setattr(main, "_normalize_target_version", lambda value: value)
1003
+ monkeypatch.setattr(main, "_latest_published_cli_version", lambda: "1.16.4")
908
1004
  monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (True, 4242))
909
1005
  monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.16.4")
910
1006
  monkeypatch.setattr(main.subprocess, "run", lambda cmd: SimpleNamespace(returncode=0))
File without changes
File without changes