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.
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/PKG-INFO +1 -1
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/daemon.py +95 -5
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/main.py +47 -9
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/runtime_evidence.py +497 -44
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/pyproject.toml +1 -1
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_auth_and_runtime_commands.py +96 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/README.md +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/setup.cfg +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.47.1 → forgexa_cli-1.47.3}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.47.
|
|
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.
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1021
|
-
"""
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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 "
|
|
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
|
|
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
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|