forgexa-cli 1.46.2__tar.gz → 1.47.0__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.46.2 → forgexa_cli-1.47.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli/agent_core.py +1 -1
  4. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli/daemon.py +434 -9
  5. forgexa_cli-1.47.0/forgexa_cli/runtime_evidence.py +583 -0
  6. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/SOURCES.txt +1 -0
  8. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/pyproject.toml +1 -1
  9. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/README.md +0 -0
  10. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli/_build_config.py +0 -0
  11. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli/_local_bind.py +0 -0
  12. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli/autoupgrade.py +0 -0
  13. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli/main.py +0 -0
  14. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli/py.typed +0 -0
  15. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  16. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  17. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/requires.txt +0 -0
  18. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  19. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/setup.cfg +0 -0
  20. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/tests/test_auth_and_runtime_commands.py +0 -0
  21. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/tests/test_silent_install.py +0 -0
  28. {forgexa_cli-1.46.2 → forgexa_cli-1.47.0}/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.46.2
3
+ Version: 1.47.0
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.46.2"
2
+ __version__ = "1.47.0"
@@ -1744,7 +1744,7 @@ def build_copilot_command(
1744
1744
 
1745
1745
  Host-only option (the daemon leaves it at its default):
1746
1746
  ``resume_session_id`` continues a previous session via
1747
- ``--resume=<id>`` (session state lives under ``~/.copilot/session-state/``).
1747
+ ``--resume=<id>`` (session state lives under ``COPILOT_HOME/session-state/``).
1748
1748
  """
1749
1749
  cmd = [
1750
1750
  binary,
@@ -75,6 +75,14 @@ except ImportError:
75
75
  except ImportError:
76
76
  import agent_core # type: ignore[no-redef] # flat script layout (desktop resources)
77
77
 
78
+ try:
79
+ from app import runtime_evidence
80
+ except ImportError:
81
+ try:
82
+ from . import runtime_evidence # type: ignore[no-redef] # CLI package layout
83
+ except ImportError:
84
+ import runtime_evidence # type: ignore[no-redef] # flat script layout (desktop resources)
85
+
78
86
  # Backwards-compatible aliases: keep every moved symbol importable from
79
87
  # app.daemon (tests patch e.g. app.daemon._kill_proc) while the single source
80
88
  # of truth lives in agent_core.
@@ -1067,7 +1075,7 @@ except (ImportError, ModuleNotFoundError):
1067
1075
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1068
1076
  # Kept in sync with pyproject.toml version via bump-version.sh.
1069
1077
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1070
- DAEMON_VERSION = "1.46.2"
1078
+ DAEMON_VERSION = "1.47.0"
1071
1079
 
1072
1080
 
1073
1081
  def _detect_client_type() -> str:
@@ -1404,6 +1412,9 @@ class TaskInfo:
1404
1412
  local_workspace_binding_id: str | None = None
1405
1413
  effective_operator_user_id: str | None = None
1406
1414
  required_commit_shas: list[str] = field(default_factory=list)
1415
+ execution_attempt_id: str | None = None
1416
+ runtime_evidence_plan: dict | None = None
1417
+ local_evidence_upload: bool = False
1407
1418
 
1408
1419
 
1409
1420
  @dataclass
@@ -1432,6 +1443,62 @@ class TaskResult:
1432
1443
  git: dict = field(default_factory=dict)
1433
1444
  # Set from the complete agent stream before stdout is retained as a bounded tail.
1434
1445
  has_verified_completion: bool = False
1446
+ execution_attempt_id: str | None = None
1447
+ evidence_summary: dict | None = None
1448
+
1449
+
1450
+ _ACTIONABLE_REFLECTION_KINDS = frozenset({
1451
+ "defect",
1452
+ "failed_check",
1453
+ "vulnerability",
1454
+ "coverage_gap",
1455
+ "requirement_gap",
1456
+ })
1457
+
1458
+
1459
+ def _reflection_finding_text(value: object, limit: int = 1000) -> str:
1460
+ return value.replace("\x00", "").strip()[:limit] if isinstance(value, str) else ""
1461
+
1462
+
1463
+ def _format_reflection_findings(findings: object) -> str:
1464
+ """Format server-filtered actionable findings for the next agent turn."""
1465
+ if not isinstance(findings, list):
1466
+ return ""
1467
+ lines: list[str] = []
1468
+ for finding in findings[:10]:
1469
+ if not isinstance(finding, dict):
1470
+ continue
1471
+ if _reflection_finding_text(finding.get("finding_kind"), 50) not in _ACTIONABLE_REFLECTION_KINDS:
1472
+ continue
1473
+ message = _reflection_finding_text(finding.get("message"))
1474
+ if not message:
1475
+ continue
1476
+ severity = _reflection_finding_text(finding.get("severity"), 20) or "major"
1477
+ dimension = _reflection_finding_text(finding.get("dimension"), 100) or "gate"
1478
+ title = _reflection_finding_text(finding.get("title"), 200)
1479
+ lines.append(f"- [{severity}] [{dimension}] {title or message}")
1480
+ if title and title != message:
1481
+ lines.append(f" Finding: {message}")
1482
+ path = _reflection_finding_text(finding.get("file"), 1000)
1483
+ line = finding.get("line")
1484
+ end_line = finding.get("end_line")
1485
+ if path:
1486
+ location = path
1487
+ if isinstance(line, int) and line > 0:
1488
+ location += f":{line}"
1489
+ if isinstance(end_line, int) and end_line >= line:
1490
+ location += f"-{end_line}"
1491
+ lines.append(f" Location: {location}")
1492
+ for label, key in (
1493
+ ("Evidence", "evidence"),
1494
+ ("Impact", "impact"),
1495
+ ("Suggested change", "suggestion"),
1496
+ ("Verification", "verification"),
1497
+ ):
1498
+ value = _reflection_finding_text(finding.get(key))
1499
+ if value:
1500
+ lines.append(f" {label}: {value}")
1501
+ return "\n".join(lines)
1435
1502
 
1436
1503
 
1437
1504
  def _filter_local_bind_task_result(result: "TaskResult") -> None:
@@ -1441,7 +1508,7 @@ def _filter_local_bind_task_result(result: "TaskResult") -> None:
1441
1508
  agent observations are classified as data that must NEVER leave the
1442
1509
  user's machine by default for workspace_resolution="local_bind" tasks —
1443
1510
  only Git-tracked artifacts (already pushed) and status/metrics/Git
1444
- metadata sync. error/failure_code/metrics/git/artifacts are left as-is
1511
+ metadata sync. error/failure_code/metrics/git/artifacts/evidence_summary are left as-is
1445
1512
  (status metadata, not raw output) — only stdout/stderr tail and raw
1446
1513
  agent observations are cleared. Mutates `result` in place; call this
1447
1514
  right before ProgressReporter.report_complete().
@@ -5457,16 +5524,27 @@ class ProcessManager:
5457
5524
 
5458
5525
  # Layer 3: Append CriticAgent reflection context if present
5459
5526
  reflection = (task.input_data or {}).get("reflection_context")
5460
- if reflection:
5527
+ if isinstance(reflection, dict):
5461
5528
  issues = reflection.get("issues", [])
5462
5529
  score = reflection.get("score", "N/A")
5463
- issues_text = "\n".join(f"- {iss}" for iss in issues)
5464
- prompt += (
5465
- "\n\n## CriticAgent Feedback (MUST address before finishing)\n"
5466
- f"Previous submission scored {score}. The following issues were identified:\n"
5467
- f"{issues_text}\n\n"
5468
- "Fix ALL listed issues. Focus on the specific problems above."
5530
+ findings_text = _format_reflection_findings(
5531
+ reflection.get("remediation_findings")
5469
5532
  )
5533
+ if findings_text:
5534
+ prompt += (
5535
+ "\n\n## CriticAgent Feedback (MUST address before finishing)\n"
5536
+ f"Previous submission scored {score}. The following frozen findings were identified:\n"
5537
+ f"{findings_text}\n\n"
5538
+ "Address each actionable finding above. Stay within the approved task scope."
5539
+ )
5540
+ else:
5541
+ issues_text = "\n".join(f"- {iss}" for iss in issues)
5542
+ prompt += (
5543
+ "\n\n## CriticAgent Feedback (MUST address before finishing)\n"
5544
+ f"Previous submission scored {score}. The following issues were identified:\n"
5545
+ f"{issues_text}\n\n"
5546
+ "Fix ALL listed issues. Focus on the specific problems above."
5547
+ )
5470
5548
 
5471
5549
  return prompt
5472
5550
 
@@ -7049,6 +7127,7 @@ class ProgressReporter:
7049
7127
  "stdout_tail": result.stdout[-20000:] if result.stdout else "",
7050
7128
  "stderr_tail": result.stderr[-5000:] if result.stderr else "",
7051
7129
  "verified_completion": result.has_verified_completion,
7130
+ "execution_attempt_id": result.execution_attempt_id,
7052
7131
  "error": result.error,
7053
7132
  "failure_code": result.failure_code,
7054
7133
  "preflight": result.preflight,
@@ -7057,6 +7136,7 @@ class ProgressReporter:
7057
7136
  "lines_removed": result.lines_removed,
7058
7137
  "artifacts": result.artifacts,
7059
7138
  "observations": result.observations,
7139
+ "evidence_summary": result.evidence_summary,
7060
7140
  "metrics": result.metrics,
7061
7141
  "git": result.git,
7062
7142
  }
@@ -7660,6 +7740,12 @@ class TaskPoller:
7660
7740
  local_workspace_binding_id=t.get("local_workspace_binding_id"),
7661
7741
  effective_operator_user_id=t.get("effective_operator_user_id"),
7662
7742
  required_commit_shas=t.get("required_commit_shas", []),
7743
+ execution_attempt_id=t.get("execution_attempt_id"),
7744
+ runtime_evidence_plan=(
7745
+ t.get("runtime_evidence_plan")
7746
+ if isinstance(t.get("runtime_evidence_plan"), dict) else None
7747
+ ),
7748
+ local_evidence_upload=t.get("local_evidence_upload") is True,
7663
7749
  ))
7664
7750
  return tasks
7665
7751
  except Exception as e:
@@ -8216,6 +8302,8 @@ class ServerConnection:
8216
8302
  "platform": platform.platform(),
8217
8303
  "python_version": platform.python_version(),
8218
8304
  "local_workspace_bindings": {"protocol_version": 2},
8305
+ "local_evidence": {"protocol_version": 1, "structured_summary": True},
8306
+ "local_evidence": {"protocol_version": 1, "structured_summary": True},
8219
8307
  },
8220
8308
  "server_url": self.server_url,
8221
8309
  }
@@ -9611,6 +9699,7 @@ class RuntimeDaemon:
9611
9699
  "machine is already using this local workspace binding"
9612
9700
  ),
9613
9701
  failure_code="local_binding_unavailable",
9702
+ execution_attempt_id=task.execution_attempt_id,
9614
9703
  ))
9615
9704
  return
9616
9705
  try:
@@ -9636,6 +9725,7 @@ class RuntimeDaemon:
9636
9725
  f"Agent '{task.agent_type}' is not available on this runtime. "
9637
9726
  f"Install it: {hint}"
9638
9727
  ),
9728
+ execution_attempt_id=task.execution_attempt_id,
9639
9729
  ))
9640
9730
  return
9641
9731
 
@@ -9668,6 +9758,7 @@ class RuntimeDaemon:
9668
9758
  error=_pf_detail,
9669
9759
  failure_code="project_preflight_failed",
9670
9760
  preflight=project_preflight,
9761
+ execution_attempt_id=task.execution_attempt_id,
9671
9762
  ))
9672
9763
  return
9673
9764
 
@@ -10339,6 +10430,23 @@ class RuntimeDaemon:
10339
10430
  result.lines_added = 0
10340
10431
  result.lines_removed = 0
10341
10432
 
10433
+ if result.status == "success":
10434
+ if is_local_bind:
10435
+ self._collect_local_bind_evidence_summary(task, result)
10436
+ else:
10437
+ await self._collect_runtime_evidence(
10438
+ workspace_path,
10439
+ task,
10440
+ result,
10441
+ before_sha=node_before_sha,
10442
+ )
10443
+ await self._collect_runtime_static_evidence(
10444
+ workspace_path,
10445
+ task,
10446
+ result,
10447
+ before_sha=node_before_sha,
10448
+ )
10449
+
10342
10450
  # 6. Report completion (include actual agent used if different from requested)
10343
10451
  result.metrics["actual_agent"] = agent.agent_id
10344
10452
  if agent.agent_id != task.agent_type:
@@ -10348,6 +10456,7 @@ class RuntimeDaemon:
10348
10456
  or fallback_reason
10349
10457
  or "validation_retry_exhausted"
10350
10458
  )
10459
+ result.execution_attempt_id = task.execution_attempt_id
10351
10460
  if is_local_bind:
10352
10461
  # See docs/designs/forgexa-ide-design.md §6.6 — full terminal
10353
10462
  # output and raw agent observations must never leave the
@@ -10363,6 +10472,7 @@ class RuntimeDaemon:
10363
10472
  await reporter.report_complete(task.task_id, TaskResult(
10364
10473
  status="failed", exit_code=-1, stdout="", stderr="",
10365
10474
  error=str(e),
10475
+ execution_attempt_id=task.execution_attempt_id,
10366
10476
  ))
10367
10477
  finally:
10368
10478
  if workspace_execution_lock is not None:
@@ -13066,6 +13176,321 @@ class RuntimeDaemon:
13066
13176
  logger.warning("Auto-commit failed: %s", e)
13067
13177
  return {"commit_error": str(e)}
13068
13178
 
13179
+ async def _run_runtime_evidence_in_worktree(
13180
+ self,
13181
+ workspace_path: Path,
13182
+ task: TaskInfo,
13183
+ after_sha: str,
13184
+ check: dict,
13185
+ execute_check: Callable[[Path, dict], Awaitable[dict]],
13186
+ ) -> dict:
13187
+ """Run one frozen evidence check in a disposable detached worktree."""
13188
+ evidence_root = workspace_path.parent / ".forgexa-evidence-worktrees"
13189
+ evidence_worktree = evidence_root / uuid4().hex
13190
+ worktree_created = False
13191
+ worktree_removed = False
13192
+ try:
13193
+ evidence_root.mkdir(parents=True, exist_ok=True, mode=0o700)
13194
+ await self.workspace_manager._git(
13195
+ "worktree", "add", "--detach", str(evidence_worktree), after_sha,
13196
+ cwd=workspace_path,
13197
+ )
13198
+ worktree_created = True
13199
+ return await execute_check(evidence_worktree, check)
13200
+ except Exception:
13201
+ logger.warning(
13202
+ "Runtime evidence worktree setup failed for task %s",
13203
+ task.task_id,
13204
+ exc_info=True,
13205
+ )
13206
+ return {
13207
+ "status": "worktree_unavailable",
13208
+ "collection_status": "unavailable",
13209
+ "failure_reason": "evidence_worktree_unavailable",
13210
+ "summary": "A detached evidence worktree could not be prepared.",
13211
+ }
13212
+ finally:
13213
+ if worktree_created:
13214
+ try:
13215
+ await self.workspace_manager._git(
13216
+ "worktree", "remove", "--force", str(evidence_worktree),
13217
+ cwd=workspace_path,
13218
+ )
13219
+ worktree_removed = True
13220
+ except Exception:
13221
+ logger.warning(
13222
+ "Runtime evidence worktree cleanup failed for task %s; retaining %s",
13223
+ task.task_id,
13224
+ evidence_worktree,
13225
+ exc_info=True,
13226
+ )
13227
+ if worktree_removed:
13228
+ shutil.rmtree(evidence_worktree, ignore_errors=True)
13229
+
13230
+ async def _collect_runtime_evidence(
13231
+ self,
13232
+ workspace_path: Path,
13233
+ task: TaskInfo,
13234
+ result: TaskResult,
13235
+ *,
13236
+ before_sha: str,
13237
+ ) -> None:
13238
+ """Run one frozen pytest coverage check in a disposable post-commit worktree."""
13239
+ if (
13240
+ task.workspace_resolution != "cloud_worktree"
13241
+ or task.node_type not in ("testing", "fix")
13242
+ or not isinstance(task.runtime_evidence_plan, dict)
13243
+ ):
13244
+ return
13245
+
13246
+ plan = task.runtime_evidence_plan
13247
+ plan_hash = plan.get("plan_hash")
13248
+ checks = plan.get("checks")
13249
+ check = next(
13250
+ (
13251
+ item for item in checks
13252
+ if isinstance(item, dict) and item.get("kind") == runtime_evidence.TEST_COVERAGE_CHECK_KIND
13253
+ ),
13254
+ None,
13255
+ ) if isinstance(checks, list) else None
13256
+ if not isinstance(check, dict):
13257
+ return
13258
+ after_sha = str((result.git or {}).get("commit_sha") or "").strip()
13259
+ if (
13260
+ plan.get("version") != runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION
13261
+ or not isinstance(plan_hash, str)
13262
+ or re.fullmatch(r"[0-9a-f]{64}", plan_hash) is None
13263
+ ):
13264
+ run = {
13265
+ "status": "invalid_plan",
13266
+ "collection_status": "unavailable",
13267
+ "failure_reason": "invalid_frozen_plan",
13268
+ "summary": "The frozen test evidence plan is invalid.",
13269
+ }
13270
+ elif re.fullmatch(r"[0-9a-fA-F]{40}", after_sha) is None:
13271
+ run = {
13272
+ "status": "source_commit_unavailable",
13273
+ "collection_status": "unavailable",
13274
+ "failure_reason": "source_commit_unavailable",
13275
+ "summary": "The committed source revision is unavailable for evidence collection.",
13276
+ }
13277
+ else:
13278
+ run = await self._run_runtime_evidence_in_worktree(
13279
+ workspace_path,
13280
+ task,
13281
+ after_sha,
13282
+ check,
13283
+ runtime_evidence.run_test_coverage_evidence,
13284
+ )
13285
+
13286
+ source_commit_range = {"after_sha": after_sha} if after_sha else {}
13287
+ if re.fullmatch(r"[0-9a-fA-F]{40}", before_sha or ""):
13288
+ source_commit_range["before_sha"] = before_sha
13289
+ report = {
13290
+ "version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
13291
+ "plan_hash": plan_hash if isinstance(plan_hash, str) else "",
13292
+ "execution_attempt_id": task.execution_attempt_id,
13293
+ "check_kind": runtime_evidence.TEST_COVERAGE_CHECK_KIND,
13294
+ "command": check.get("command") if isinstance(check, dict) else "",
13295
+ "source_commit_range": source_commit_range,
13296
+ "result": run,
13297
+ }
13298
+ report_content = json.dumps(report, sort_keys=True, separators=(",", ":"))
13299
+ report_checksum = hashlib.sha256(report_content.encode("utf-8")).hexdigest()
13300
+ artifact_path = (
13301
+ f".forgexa/evidence/{task.task_id}/{after_sha[:12] or 'unavailable'}/"
13302
+ "test-coverage.json"
13303
+ )
13304
+ artifact_ref = {"path": artifact_path, "checksum": report_checksum}
13305
+ result.artifacts.append({
13306
+ "path": artifact_path,
13307
+ "content": report_content,
13308
+ "content_hash": report_checksum,
13309
+ "type": "application/vnd.forgexa.gate-evidence+json",
13310
+ "system_generated": True,
13311
+ "artifact_role": "gate_evidence_report",
13312
+ })
13313
+
13314
+ evidence_identity = {
13315
+ "version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
13316
+ "plan_hash": report["plan_hash"],
13317
+ "check_kind": runtime_evidence.TEST_COVERAGE_CHECK_KIND,
13318
+ "execution_attempt_id": task.execution_attempt_id,
13319
+ "source_commit_range": source_commit_range,
13320
+ }
13321
+ test_observation = {
13322
+ "type": "test_results",
13323
+ "collection_status": run.get("collection_status"),
13324
+ "summary": run.get("summary"),
13325
+ "tool": "pytest",
13326
+ "command": check.get("command") if isinstance(check, dict) else "",
13327
+ "artifact_refs": [artifact_ref],
13328
+ "runtime_evidence": evidence_identity,
13329
+ }
13330
+ for key in ("test_pass_rate", "passed", "failed", "skipped", "errors", "total", "failed_tests"):
13331
+ if key in run:
13332
+ test_observation[key] = run[key]
13333
+ if run.get("failure_reason"):
13334
+ test_observation["collection_failure_reason"] = run["failure_reason"]
13335
+ result.observations.append(test_observation)
13336
+
13337
+ coverage_observation = {
13338
+ "type": "coverage_results",
13339
+ "tool": "pytest-cov",
13340
+ "command": check.get("command") if isinstance(check, dict) else "",
13341
+ "artifact_refs": [artifact_ref],
13342
+ "runtime_evidence": evidence_identity,
13343
+ }
13344
+ line_coverage = run.get("line_coverage")
13345
+ if run.get("collection_status") == "completed" and isinstance(line_coverage, (int, float)):
13346
+ coverage_observation.update({
13347
+ "collection_status": "completed",
13348
+ "line_coverage": float(line_coverage),
13349
+ "summary": f"pytest coverage completed: {round(float(line_coverage) * 100)}% line coverage.",
13350
+ })
13351
+ elif run.get("collection_status") == "completed":
13352
+ coverage_observation.update({
13353
+ "collection_status": "incomplete",
13354
+ "collection_failure_reason": "coverage_report_unparseable",
13355
+ "summary": "pytest completed but did not emit a parseable coverage total.",
13356
+ })
13357
+ else:
13358
+ coverage_observation.update({
13359
+ "collection_status": run.get("collection_status", "unavailable"),
13360
+ "collection_failure_reason": run.get("failure_reason", "evidence_unavailable"),
13361
+ "summary": run.get("summary", "Coverage evidence is unavailable."),
13362
+ })
13363
+ result.observations.append(coverage_observation)
13364
+
13365
+ def _collect_local_bind_evidence_summary(
13366
+ self,
13367
+ task: TaskInfo,
13368
+ result: TaskResult,
13369
+ ) -> None:
13370
+ """Extract the allowed local-bind test summary before raw output is cleared."""
13371
+ if (
13372
+ not task.local_evidence_upload
13373
+ or task.node_type not in ("testing", "fix")
13374
+ ):
13375
+ return
13376
+ raw_output = "\n".join(part for part in (result.stdout, result.stderr) if part)
13377
+ pytest_summary = runtime_evidence.build_local_pytest_evidence_summary(raw_output)
13378
+ ruff_summary = runtime_evidence.build_local_ruff_evidence_summary(raw_output)
13379
+ summary = {"version": runtime_evidence.LOCAL_EVIDENCE_SUMMARY_VERSION}
13380
+ if pytest_summary is not None:
13381
+ summary["pytest"] = pytest_summary["pytest"]
13382
+ if ruff_summary is not None:
13383
+ summary["ruff"] = ruff_summary["ruff"]
13384
+ result.evidence_summary = summary if len(summary) > 1 else None
13385
+
13386
+ async def _collect_runtime_static_evidence(
13387
+ self,
13388
+ workspace_path: Path,
13389
+ task: TaskInfo,
13390
+ result: TaskResult,
13391
+ *,
13392
+ before_sha: str,
13393
+ ) -> None:
13394
+ """Run a frozen Ruff check in a disposable post-commit worktree."""
13395
+ if (
13396
+ task.workspace_resolution != "cloud_worktree"
13397
+ or task.node_type not in ("testing", "fix")
13398
+ or not isinstance(task.runtime_evidence_plan, dict)
13399
+ ):
13400
+ return
13401
+
13402
+ plan = task.runtime_evidence_plan
13403
+ plan_hash = plan.get("plan_hash")
13404
+ checks = plan.get("checks")
13405
+ check = next(
13406
+ (
13407
+ item for item in checks
13408
+ if isinstance(item, dict) and item.get("kind") == runtime_evidence.STATIC_CHECK_KIND
13409
+ ),
13410
+ None,
13411
+ ) if isinstance(checks, list) else None
13412
+ if not isinstance(check, dict):
13413
+ return
13414
+ after_sha = str((result.git or {}).get("commit_sha") or "").strip()
13415
+ if (
13416
+ plan.get("version") != runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION
13417
+ or not isinstance(plan_hash, str)
13418
+ or re.fullmatch(r"[0-9a-f]{64}", plan_hash) is None
13419
+ ):
13420
+ run = {
13421
+ "status": "invalid_plan",
13422
+ "collection_status": "unavailable",
13423
+ "failure_reason": "invalid_frozen_plan",
13424
+ "summary": "The frozen static-check evidence plan is invalid.",
13425
+ }
13426
+ elif re.fullmatch(r"[0-9a-fA-F]{40}", after_sha) is None:
13427
+ run = {
13428
+ "status": "source_commit_unavailable",
13429
+ "collection_status": "unavailable",
13430
+ "failure_reason": "source_commit_unavailable",
13431
+ "summary": "The committed source revision is unavailable for static checking.",
13432
+ }
13433
+ else:
13434
+ run = await self._run_runtime_evidence_in_worktree(
13435
+ workspace_path,
13436
+ task,
13437
+ after_sha,
13438
+ check,
13439
+ runtime_evidence.run_static_check_evidence,
13440
+ )
13441
+
13442
+ source_commit_range = {"after_sha": after_sha} if after_sha else {}
13443
+ if re.fullmatch(r"[0-9a-fA-F]{40}", before_sha or ""):
13444
+ source_commit_range["before_sha"] = before_sha
13445
+ report = {
13446
+ "version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
13447
+ "plan_hash": plan_hash if isinstance(plan_hash, str) else "",
13448
+ "execution_attempt_id": task.execution_attempt_id,
13449
+ "check_kind": runtime_evidence.STATIC_CHECK_KIND,
13450
+ "command": check.get("command"),
13451
+ "source_commit_range": source_commit_range,
13452
+ "result": run,
13453
+ }
13454
+ report_content = json.dumps(report, sort_keys=True, separators=(",", ":"))
13455
+ report_checksum = hashlib.sha256(report_content.encode("utf-8")).hexdigest()
13456
+ artifact_path = (
13457
+ f".forgexa/evidence/{task.task_id}/{after_sha[:12] or 'unavailable'}/"
13458
+ "static-check.json"
13459
+ )
13460
+ artifact_ref = {"path": artifact_path, "checksum": report_checksum}
13461
+ result.artifacts.append({
13462
+ "path": artifact_path,
13463
+ "content": report_content,
13464
+ "content_hash": report_checksum,
13465
+ "type": "application/vnd.forgexa.gate-evidence+json",
13466
+ "system_generated": True,
13467
+ "artifact_role": "gate_evidence_report",
13468
+ })
13469
+
13470
+ evidence_identity = {
13471
+ "version": runtime_evidence.RUNTIME_EVIDENCE_PLAN_VERSION,
13472
+ "plan_hash": report["plan_hash"],
13473
+ "check_kind": runtime_evidence.STATIC_CHECK_KIND,
13474
+ "execution_attempt_id": task.execution_attempt_id,
13475
+ "source_commit_range": source_commit_range,
13476
+ }
13477
+ observation = {
13478
+ "type": "lint_results",
13479
+ "collection_status": run.get("collection_status"),
13480
+ "summary": run.get("summary"),
13481
+ "lint_tool": "ruff",
13482
+ "lint_command": check.get("command"),
13483
+ "command": check.get("command"),
13484
+ "artifact_refs": [artifact_ref],
13485
+ "runtime_evidence": evidence_identity,
13486
+ }
13487
+ for key in ("lint_score", "issues", "type_errors", "truncated_count"):
13488
+ if key in run:
13489
+ observation[key] = run[key]
13490
+ if run.get("failure_reason"):
13491
+ observation["collection_failure_reason"] = run["failure_reason"]
13492
+ result.observations.append(observation)
13493
+
13069
13494
  async def _collect_staged_diff_stats(self, cwd: Path) -> dict:
13070
13495
  """Collect staged diff stats for building a rich commit message."""
13071
13496
  status_map: dict[str, str] = {}
@@ -0,0 +1,583 @@
1
+ """Portable planning and parsing for controlled runtime evidence checks."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import shlex
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ RUNTIME_EVIDENCE_PLAN_VERSION = 1
15
+ TEST_COVERAGE_CHECK_KIND = "test_coverage"
16
+ STATIC_CHECK_KIND = "static_check"
17
+ LOCAL_EVIDENCE_SUMMARY_VERSION = 1
18
+ _DEFAULT_TIMEOUT_SECONDS = 600
19
+ _MAX_FAILED_TESTS = 20
20
+ _MAX_LINT_ISSUES = 50
21
+ _MAX_OUTPUT_CHARS = 100_000
22
+ _SHELL_METACHARACTERS = (";", "`", "$", ">", "<", "&", "|", "\n", "\r", "\x00")
23
+ _SENSITIVE_ARGUMENT = re.compile(
24
+ r"^-{1,2}(?:api[-_]?key|authorization|password|secret|token)(?:=|$)",
25
+ re.IGNORECASE,
26
+ )
27
+ _WINDOWS_ABSOLUTE_PATH = re.compile(r"^[A-Za-z]:[\\/]")
28
+ _SAFE_TRACEBACK_MODES = frozenset({"auto", "long", "short", "line", "native", "no"})
29
+ _SAFE_COVERAGE_REPORTS = frozenset({"term", "term-missing"})
30
+ _RUFF_STATIC_CHECK_ARGV = (
31
+ "python", "-m", "ruff", "check", ".", "--output-format=json",
32
+ )
33
+ _SAFE_LOCAL_TEST_NAME = re.compile(r"^[A-Za-z0-9_./:-]+$")
34
+ _SAFE_LOCAL_RUFF_RULE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,199}$")
35
+
36
+
37
+ def _safe_workspace_path(value: str, *, allow_node_id: bool = False) -> bool:
38
+ candidate = value.strip().replace("\\", "/")
39
+ if allow_node_id:
40
+ candidate = candidate.split("::", 1)[0]
41
+ if (
42
+ not candidate
43
+ or candidate.startswith(("/", "~", "@"))
44
+ or _WINDOWS_ABSOLUTE_PATH.match(candidate)
45
+ ):
46
+ return False
47
+ return all(part not in {"", ".."} for part in candidate.split("/"))
48
+
49
+
50
+ def pytest_coverage_command_argv(command: object) -> list[str] | None:
51
+ """Return a whitelisted pytest-with-coverage argv, or ``None`` when disallowed."""
52
+ if not isinstance(command, str) or not command.strip():
53
+ return None
54
+ if any(character in command for character in _SHELL_METACHARACTERS):
55
+ return None
56
+ try:
57
+ argv = shlex.split(command)
58
+ except ValueError:
59
+ return None
60
+ if argv[:3] != ["python", "-m", "pytest"]:
61
+ return None
62
+ coverage_seen = False
63
+ for argument in argv[3:]:
64
+ if _SENSITIVE_ARGUMENT.match(argument):
65
+ return None
66
+ if re.fullmatch(r"-q+", argument) or argument == "--disable-warnings":
67
+ continue
68
+ if argument.startswith("--tb="):
69
+ if argument.removeprefix("--tb=") in _SAFE_TRACEBACK_MODES:
70
+ continue
71
+ return None
72
+ if argument.startswith("--cov="):
73
+ if _safe_workspace_path(argument.removeprefix("--cov=")):
74
+ coverage_seen = True
75
+ continue
76
+ return None
77
+ if argument.startswith("--cov-report="):
78
+ if argument.removeprefix("--cov-report=") in _SAFE_COVERAGE_REPORTS:
79
+ continue
80
+ return None
81
+ if argument.startswith("-") or not _safe_workspace_path(argument, allow_node_id=True):
82
+ return None
83
+ if not coverage_seen:
84
+ return None
85
+ return argv
86
+
87
+
88
+ def ruff_static_check_command_argv(command: object) -> list[str] | None:
89
+ """Return the sole supported Ruff static-check argv, or ``None``."""
90
+ if not isinstance(command, str) or not command.strip():
91
+ return None
92
+ if any(character in command for character in _SHELL_METACHARACTERS):
93
+ return None
94
+ try:
95
+ argv = shlex.split(command)
96
+ except ValueError:
97
+ return None
98
+ return list(_RUFF_STATIC_CHECK_ARGV) if tuple(argv) == _RUFF_STATIC_CHECK_ARGV else None
99
+
100
+
101
+ def _single_hook_command(policy: object, hook_name: str) -> str | None:
102
+ hooks = policy.get("hooks") if isinstance(policy, dict) else None
103
+ configured = hooks.get(hook_name) if isinstance(hooks, dict) else None
104
+ if isinstance(configured, str):
105
+ return configured
106
+ if (
107
+ isinstance(configured, list)
108
+ and len(configured) == 1
109
+ and isinstance(configured[0], str)
110
+ ):
111
+ return configured[0]
112
+ return None
113
+
114
+
115
+ def _finalize_evidence_plan(
116
+ checks: list[dict[str, Any]],
117
+ policy_snapshot: object,
118
+ ) -> dict[str, Any] | None:
119
+ if not checks:
120
+ return None
121
+ snapshot = {
122
+ key: value
123
+ for key, value in (policy_snapshot.items() if isinstance(policy_snapshot, dict) else [])
124
+ if key in {"id", "version", "checksum"} and value is not None
125
+ }
126
+ plan: dict[str, Any] = {
127
+ "version": RUNTIME_EVIDENCE_PLAN_VERSION,
128
+ "checks": checks,
129
+ }
130
+ if snapshot:
131
+ plan["policy_snapshot"] = snapshot
132
+ serialized = json.dumps(plan, sort_keys=True, separators=(",", ":"))
133
+ plan["plan_hash"] = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
134
+ return plan
135
+
136
+
137
+ def _test_coverage_check(policy: object) -> dict[str, Any] | None:
138
+ command = _single_hook_command(policy, "test")
139
+ argv = pytest_coverage_command_argv(command)
140
+ if argv is None or command is None:
141
+ return None
142
+ return {
143
+ "kind": TEST_COVERAGE_CHECK_KIND,
144
+ "tool": "pytest",
145
+ "command": command.strip(),
146
+ "argv": argv,
147
+ "timeout_seconds": _DEFAULT_TIMEOUT_SECONDS,
148
+ "report_format": "pytest_stdout_coverage",
149
+ }
150
+
151
+
152
+ def _static_check(policy: object) -> dict[str, Any] | None:
153
+ command = _single_hook_command(policy, "lint")
154
+ argv = ruff_static_check_command_argv(command)
155
+ if argv is None or command is None:
156
+ return None
157
+ return {
158
+ "kind": STATIC_CHECK_KIND,
159
+ "tool": "ruff",
160
+ "command": command.strip(),
161
+ "argv": argv,
162
+ "timeout_seconds": _DEFAULT_TIMEOUT_SECONDS,
163
+ "report_format": "ruff_json",
164
+ }
165
+
166
+
167
+ def build_test_coverage_evidence_plan(
168
+ policy: object,
169
+ policy_snapshot: object = None,
170
+ ) -> dict[str, Any] | None:
171
+ """Freeze one opt-in, server-approved pytest coverage check for a graph."""
172
+ check = _test_coverage_check(policy)
173
+ return _finalize_evidence_plan([check] if check is not None else [], policy_snapshot)
174
+
175
+
176
+ def build_runtime_evidence_plan(
177
+ policy: object,
178
+ policy_snapshot: object = None,
179
+ ) -> dict[str, Any] | None:
180
+ """Freeze all supported, opt-in runtime evidence checks for one graph."""
181
+ checks = [
182
+ check
183
+ for check in (_test_coverage_check(policy), _static_check(policy))
184
+ if check is not None
185
+ ]
186
+ return _finalize_evidence_plan(checks, policy_snapshot)
187
+
188
+
189
+ def parse_pytest_coverage_output(output: object, exit_code: int) -> dict[str, Any]:
190
+ """Parse bounded pytest stdout into a non-sensitive evidence summary."""
191
+ text = output[:_MAX_OUTPUT_CHARS] if isinstance(output, str) else ""
192
+ if "No module named pytest" in text:
193
+ return {
194
+ "status": "tool_unavailable",
195
+ "collection_status": "unavailable",
196
+ "failure_reason": "pytest_unavailable",
197
+ "summary": "pytest is unavailable on this runtime.",
198
+ }
199
+ if re.search(r"\bno tests ran\b", text, re.IGNORECASE):
200
+ return {
201
+ "status": "not_executed",
202
+ "collection_status": "not_executed",
203
+ "failure_reason": "no_tests_collected",
204
+ "summary": "pytest completed without collecting tests.",
205
+ }
206
+
207
+ counts = {
208
+ "passed": _pytest_count(text, "passed"),
209
+ "failed": _pytest_count(text, "failed"),
210
+ "skipped": _pytest_count(text, "skipped"),
211
+ "errors": _pytest_count(text, "error"),
212
+ }
213
+ total = sum(counts.values())
214
+ if total == 0:
215
+ return {
216
+ "status": "parse_failed",
217
+ "collection_status": "incomplete",
218
+ "failure_reason": "pytest_summary_unparseable",
219
+ "summary": "pytest output did not contain a parseable test summary.",
220
+ }
221
+
222
+ coverage_match = re.search(
223
+ r"^TOTAL\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)%",
224
+ text,
225
+ re.MULTILINE,
226
+ )
227
+ coverage = float(coverage_match.group(1)) / 100.0 if coverage_match else None
228
+ failed_tests = _failed_test_names(text)
229
+ passed = counts["passed"]
230
+ summary = (
231
+ f"pytest completed: {passed} passed, {counts['failed']} failed, "
232
+ f"{counts['skipped']} skipped, {counts['errors']} errors."
233
+ )
234
+ return {
235
+ "status": "completed",
236
+ "collection_status": "completed",
237
+ "test_pass_rate": passed / total,
238
+ "passed": passed,
239
+ "failed": counts["failed"],
240
+ "skipped": counts["skipped"],
241
+ "errors": counts["errors"],
242
+ "total": total,
243
+ "failed_tests": [{"name": name} for name in failed_tests],
244
+ "line_coverage": coverage,
245
+ "summary": summary,
246
+ "exit_code": exit_code,
247
+ }
248
+
249
+
250
+ def build_local_pytest_evidence_summary(output: object) -> dict[str, Any] | None:
251
+ """Return a secret-free, reported-only local-bind pytest summary."""
252
+ result = parse_pytest_coverage_output(output, 0)
253
+ if result.get("collection_status") != "completed":
254
+ return None
255
+ counts = {
256
+ key: result.get(key)
257
+ for key in ("passed", "failed", "skipped", "errors")
258
+ }
259
+ if (
260
+ not all(
261
+ isinstance(value, int) and not isinstance(value, bool) and value >= 0
262
+ for value in counts.values()
263
+ )
264
+ or sum(counts.values()) == 0
265
+ ):
266
+ return None
267
+ pytest_summary: dict[str, Any] = counts
268
+ failed_tests: list[dict[str, str]] = []
269
+ for failed_test in result.get("failed_tests", []):
270
+ name = failed_test.get("name") if isinstance(failed_test, dict) else None
271
+ safe_name = _safe_local_test_name(name)
272
+ if safe_name is not None:
273
+ failed_tests.append({"name": safe_name})
274
+ if failed_tests:
275
+ pytest_summary["failed_tests"] = failed_tests
276
+ truncated_count = _local_failed_test_truncated_count(output)
277
+ if truncated_count:
278
+ pytest_summary["truncated_count"] = truncated_count
279
+ line_coverage = result.get("line_coverage")
280
+ if isinstance(line_coverage, (int, float)) and not isinstance(line_coverage, bool):
281
+ pytest_summary["line_coverage"] = float(line_coverage)
282
+ return {
283
+ "version": LOCAL_EVIDENCE_SUMMARY_VERSION,
284
+ "pytest": pytest_summary,
285
+ }
286
+
287
+
288
+ def build_local_ruff_evidence_summary(output: object) -> dict[str, Any] | None:
289
+ """Return a secret-free, reported-only local-bind Ruff summary."""
290
+ text = output[:_MAX_OUTPUT_CHARS] if isinstance(output, str) else ""
291
+ try:
292
+ raw_findings = json.loads(text)
293
+ except json.JSONDecodeError:
294
+ return None
295
+ if not isinstance(raw_findings, list):
296
+ return None
297
+ result = parse_ruff_json_output(text, 0)
298
+ if result.get("collection_status") != "completed":
299
+ return None
300
+ issues: list[dict[str, Any]] = []
301
+ for issue in result.get("issues", []):
302
+ rule = issue.get("rule") if isinstance(issue, dict) else None
303
+ if not isinstance(rule, str) or _SAFE_LOCAL_RUFF_RULE.fullmatch(rule) is None:
304
+ continue
305
+ summary_issue: dict[str, Any] = {"rule": rule}
306
+ for key in ("file", "line", "column", "end_line", "end_column"):
307
+ value = issue.get(key)
308
+ if isinstance(value, str) or (
309
+ isinstance(value, int) and not isinstance(value, bool) and value > 0
310
+ ):
311
+ summary_issue[key] = value
312
+ issues.append(summary_issue)
313
+ if raw_findings and not issues:
314
+ return None
315
+ ruff_summary: dict[str, Any] = {"issues": issues}
316
+ truncated_count = result.get("truncated_count")
317
+ if isinstance(truncated_count, int) and not isinstance(truncated_count, bool) and truncated_count > 0:
318
+ ruff_summary["truncated_count"] = truncated_count
319
+ return {
320
+ "version": LOCAL_EVIDENCE_SUMMARY_VERSION,
321
+ "ruff": ruff_summary,
322
+ }
323
+
324
+
325
+ def parse_ruff_json_output(output: object, exit_code: int) -> dict[str, Any]:
326
+ """Parse Ruff JSON into bounded, repository-relative static findings."""
327
+ text = output[:_MAX_OUTPUT_CHARS] if isinstance(output, str) else ""
328
+ if "No module named ruff" in text:
329
+ return {
330
+ "status": "tool_unavailable",
331
+ "collection_status": "unavailable",
332
+ "failure_reason": "ruff_unavailable",
333
+ "summary": "Ruff is unavailable on this runtime.",
334
+ }
335
+ if exit_code not in (0, 1):
336
+ return {
337
+ "status": "execution_failed",
338
+ "collection_status": "incomplete",
339
+ "failure_reason": "ruff_execution_failed",
340
+ "summary": f"Ruff exited with status {exit_code} before completing static checks.",
341
+ "exit_code": exit_code,
342
+ }
343
+ try:
344
+ raw_findings = json.loads(text)
345
+ except json.JSONDecodeError:
346
+ return {
347
+ "status": "parse_failed",
348
+ "collection_status": "incomplete",
349
+ "failure_reason": "ruff_json_unparseable",
350
+ "summary": "Ruff did not emit a parseable JSON report.",
351
+ }
352
+ if not isinstance(raw_findings, list):
353
+ return {
354
+ "status": "parse_failed",
355
+ "collection_status": "incomplete",
356
+ "failure_reason": "ruff_json_invalid_shape",
357
+ "summary": "Ruff emitted an invalid JSON report shape.",
358
+ }
359
+
360
+ issues: list[dict[str, Any]] = []
361
+ for raw_finding in raw_findings:
362
+ if not isinstance(raw_finding, dict):
363
+ continue
364
+ message = raw_finding.get("message")
365
+ rule = raw_finding.get("code")
366
+ if not isinstance(message, str) or not message.strip() or not isinstance(rule, str) or not rule.strip():
367
+ continue
368
+ finding: dict[str, Any] = {
369
+ "rule": rule.strip()[:200],
370
+ "message": message.strip()[:1000],
371
+ }
372
+ filename = raw_finding.get("filename")
373
+ if isinstance(filename, str) and _safe_workspace_path(filename):
374
+ finding["file"] = filename.strip().replace("\\", "/")
375
+ location = raw_finding.get("location")
376
+ if isinstance(location, dict):
377
+ row = location.get("row")
378
+ column = location.get("column")
379
+ if isinstance(row, int) and not isinstance(row, bool) and row > 0:
380
+ finding["line"] = row
381
+ if isinstance(column, int) and not isinstance(column, bool) and column > 0:
382
+ finding["column"] = column
383
+ end_location = raw_finding.get("end_location")
384
+ if isinstance(end_location, dict):
385
+ row = end_location.get("row")
386
+ column = end_location.get("column")
387
+ if isinstance(row, int) and not isinstance(row, bool) and row > 0:
388
+ finding["end_line"] = row
389
+ if isinstance(column, int) and not isinstance(column, bool) and column > 0:
390
+ finding["end_column"] = column
391
+ issues.append(finding)
392
+ if len(issues) >= _MAX_LINT_ISSUES:
393
+ break
394
+
395
+ result: dict[str, Any] = {
396
+ "status": "completed",
397
+ "collection_status": "completed",
398
+ "lint_score": 1.0 if not issues else 0.0,
399
+ "issues": issues,
400
+ "type_errors": [],
401
+ "summary": f"Ruff completed: {len(issues)} issue(s).",
402
+ "exit_code": exit_code,
403
+ }
404
+ if len(raw_findings) > len(issues):
405
+ result["truncated_count"] = len(raw_findings) - len(issues)
406
+ return result
407
+
408
+
409
+ async def run_test_coverage_evidence(
410
+ workspace_path: str | Path,
411
+ check: object,
412
+ ) -> dict[str, Any]:
413
+ """Run one frozen pytest coverage check without invoking a shell."""
414
+ command = check.get("command") if isinstance(check, dict) else None
415
+ argv = pytest_coverage_command_argv(command)
416
+ if argv is None:
417
+ return {
418
+ "status": "invalid_plan",
419
+ "collection_status": "unavailable",
420
+ "failure_reason": "invalid_frozen_command",
421
+ "summary": "The frozen test evidence command is invalid.",
422
+ }
423
+ timeout = check.get("timeout_seconds") if isinstance(check, dict) else None
424
+ timeout_seconds = (
425
+ timeout if isinstance(timeout, int) and 1 <= timeout <= 3600 else _DEFAULT_TIMEOUT_SECONDS
426
+ )
427
+ started_at = time.monotonic()
428
+ try:
429
+ process = await asyncio.create_subprocess_exec(
430
+ *argv,
431
+ cwd=str(Path(workspace_path)),
432
+ stdout=asyncio.subprocess.PIPE,
433
+ stderr=asyncio.subprocess.PIPE,
434
+ )
435
+ except FileNotFoundError:
436
+ return {
437
+ "status": "tool_unavailable",
438
+ "collection_status": "unavailable",
439
+ "failure_reason": "python_unavailable",
440
+ "summary": "Python is unavailable on this runtime.",
441
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
442
+ }
443
+ except OSError:
444
+ return {
445
+ "status": "tool_unavailable",
446
+ "collection_status": "unavailable",
447
+ "failure_reason": "pytest_start_failed",
448
+ "summary": "pytest could not be started on this runtime.",
449
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
450
+ }
451
+
452
+ try:
453
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_seconds)
454
+ except asyncio.TimeoutError:
455
+ process.kill()
456
+ await process.communicate()
457
+ return {
458
+ "status": "timed_out",
459
+ "collection_status": "unavailable",
460
+ "failure_reason": "timeout",
461
+ "summary": f"pytest evidence collection timed out after {timeout_seconds}s.",
462
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
463
+ }
464
+
465
+ output = (stdout + b"\n" + stderr).decode("utf-8", errors="replace")
466
+ report = parse_pytest_coverage_output(output, process.returncode or 0)
467
+ report["duration_ms"] = int((time.monotonic() - started_at) * 1000)
468
+ report["tool"] = "pytest"
469
+ report["command"] = command
470
+ return report
471
+
472
+
473
+ async def run_static_check_evidence(
474
+ workspace_path: str | Path,
475
+ check: object,
476
+ ) -> dict[str, Any]:
477
+ """Run the frozen Ruff static check without invoking a shell."""
478
+ command = check.get("command") if isinstance(check, dict) else None
479
+ argv = ruff_static_check_command_argv(command)
480
+ if argv is None:
481
+ return {
482
+ "status": "invalid_plan",
483
+ "collection_status": "unavailable",
484
+ "failure_reason": "invalid_frozen_command",
485
+ "summary": "The frozen static-check command is invalid.",
486
+ }
487
+ timeout = check.get("timeout_seconds") if isinstance(check, dict) else None
488
+ timeout_seconds = (
489
+ timeout if isinstance(timeout, int) and 1 <= timeout <= 3600 else _DEFAULT_TIMEOUT_SECONDS
490
+ )
491
+ started_at = time.monotonic()
492
+ try:
493
+ process = await asyncio.create_subprocess_exec(
494
+ *argv,
495
+ cwd=str(Path(workspace_path)),
496
+ stdout=asyncio.subprocess.PIPE,
497
+ stderr=asyncio.subprocess.PIPE,
498
+ )
499
+ except FileNotFoundError:
500
+ return {
501
+ "status": "tool_unavailable",
502
+ "collection_status": "unavailable",
503
+ "failure_reason": "python_unavailable",
504
+ "summary": "Python is unavailable on this runtime.",
505
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
506
+ }
507
+ except OSError:
508
+ return {
509
+ "status": "tool_unavailable",
510
+ "collection_status": "unavailable",
511
+ "failure_reason": "ruff_start_failed",
512
+ "summary": "Ruff could not be started on this runtime.",
513
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
514
+ }
515
+
516
+ try:
517
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_seconds)
518
+ except asyncio.TimeoutError:
519
+ process.kill()
520
+ await process.communicate()
521
+ return {
522
+ "status": "timed_out",
523
+ "collection_status": "unavailable",
524
+ "failure_reason": "timeout",
525
+ "summary": f"Ruff evidence collection timed out after {timeout_seconds}s.",
526
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
527
+ }
528
+
529
+ stdout_text = stdout.decode("utf-8", errors="replace")
530
+ stderr_text = stderr.decode("utf-8", errors="replace")
531
+ if "No module named ruff" in f"{stdout_text}\n{stderr_text}":
532
+ report = {
533
+ "status": "tool_unavailable",
534
+ "collection_status": "unavailable",
535
+ "failure_reason": "ruff_unavailable",
536
+ "summary": "Ruff is unavailable on this runtime.",
537
+ }
538
+ else:
539
+ report = parse_ruff_json_output(stdout_text, process.returncode or 0)
540
+ report["duration_ms"] = int((time.monotonic() - started_at) * 1000)
541
+ report["tool"] = "ruff"
542
+ report["command"] = command
543
+ return report
544
+
545
+
546
+ def _pytest_count(output: str, label: str) -> int:
547
+ match = re.search(rf"(\d+)\s+{label}s?\b", output, re.IGNORECASE)
548
+ return int(match.group(1)) if match else 0
549
+
550
+
551
+ def _failed_test_names(output: str) -> list[str]:
552
+ names: list[str] = []
553
+ seen: set[str] = set()
554
+ for match in re.finditer(r"^(?:FAILED|ERROR)\s+([^\s]+)", output, re.MULTILINE):
555
+ name = match.group(1).strip()
556
+ if not name or name.startswith(("/", "\\")) or ".." in name:
557
+ continue
558
+ if name not in seen:
559
+ seen.add(name)
560
+ names.append(name[:500])
561
+ if len(names) >= _MAX_FAILED_TESTS:
562
+ break
563
+ return names
564
+
565
+
566
+ def _safe_local_test_name(value: object) -> str | None:
567
+ if not isinstance(value, str):
568
+ return None
569
+ name = value.split("[", 1)[0].strip()
570
+ if not name or len(name) > 500 or _SAFE_LOCAL_TEST_NAME.fullmatch(name) is None:
571
+ return None
572
+ return name
573
+
574
+
575
+ def _local_failed_test_truncated_count(output: object) -> int:
576
+ if not isinstance(output, str):
577
+ return 0
578
+ names = {
579
+ name
580
+ for match in re.finditer(r"^(?:FAILED|ERROR)\s+([^\s]+)", output, re.MULTILINE)
581
+ if (name := _safe_local_test_name(match.group(1))) is not None
582
+ }
583
+ return max(0, len(names) - _MAX_FAILED_TESTS)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.46.2
3
+ Version: 1.47.0
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
@@ -8,6 +8,7 @@ forgexa_cli/autoupgrade.py
8
8
  forgexa_cli/daemon.py
9
9
  forgexa_cli/main.py
10
10
  forgexa_cli/py.typed
11
+ forgexa_cli/runtime_evidence.py
11
12
  forgexa_cli.egg-info/PKG-INFO
12
13
  forgexa_cli.egg-info/SOURCES.txt
13
14
  forgexa_cli.egg-info/dependency_links.txt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.46.2"
3
+ version = "1.47.0"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes