forgexa-cli 1.46.3__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.
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/PKG-INFO +1 -1
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli/agent_core.py +1 -1
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli/daemon.py +434 -9
- forgexa_cli-1.47.0/forgexa_cli/runtime_evidence.py +583 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/SOURCES.txt +1 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/pyproject.toml +1 -1
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/README.md +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/setup.cfg +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.46.3 → forgexa_cli-1.47.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
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
|
|
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.
|
|
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
|
-
|
|
5464
|
-
|
|
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)
|
|
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
|