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