traceforge-toolkit 0.1.0__py3-none-any.whl
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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
traceforge/enricher.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Enricher — stateful per-session event enrichment (tool pairing, classification, phase)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from traceforge.classify import classify_shell, classify_tool, get_default_engine
|
|
10
|
+
from traceforge.classify.cmd import classify_cmd_command
|
|
11
|
+
from traceforge.classify.config import ClassificationEngine, ClassifyConfig, load_config
|
|
12
|
+
from traceforge.classify.core import Classification, PhaseSegment
|
|
13
|
+
from traceforge.classify.coding import CodingMechanism, CodingScope
|
|
14
|
+
from traceforge.classify.powershell import classify_powershell_command
|
|
15
|
+
from traceforge.classify.risk import assess_risk, assess_tool_risk
|
|
16
|
+
from traceforge.classify.tools import normalize_tool_name
|
|
17
|
+
from traceforge.classify.workflow import Phase, Visibility
|
|
18
|
+
from traceforge.types import EventKind, EventMetadata, SessionEvent
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Enricher:
|
|
24
|
+
"""Stateful per-session enricher that pairs tool events and classifies them."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
custom_classifications: dict[str, Classification] | None = None,
|
|
29
|
+
config: ClassifyConfig | None = None,
|
|
30
|
+
config_path: Path | str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Args:
|
|
34
|
+
custom_classifications: Optional tool_name→Classification map
|
|
35
|
+
that extends/overrides built-in classifications.
|
|
36
|
+
config: Optional pre-built ClassifyConfig (takes priority over config_path).
|
|
37
|
+
config_path: Optional path to a YAML config file. If neither config nor
|
|
38
|
+
config_path is provided, the default discovery chain is used.
|
|
39
|
+
"""
|
|
40
|
+
self._custom_classifications = custom_classifications
|
|
41
|
+
self._pending: dict[tuple[str, str], SessionEvent] = {}
|
|
42
|
+
|
|
43
|
+
# Build engine eagerly so config errors surface at construction time
|
|
44
|
+
if config is not None:
|
|
45
|
+
self._engine: ClassificationEngine = ClassificationEngine(config)
|
|
46
|
+
elif config_path is not None:
|
|
47
|
+
self._engine = ClassificationEngine(load_config(Path(config_path)))
|
|
48
|
+
else:
|
|
49
|
+
self._engine = get_default_engine()
|
|
50
|
+
|
|
51
|
+
def process(self, event: SessionEvent) -> SessionEvent | list[SessionEvent] | None:
|
|
52
|
+
"""Enrich a single event. Returns None if event is buffered (tool_start waiting
|
|
53
|
+
for its tool_complete pair). Returns enriched event when ready. May return a list
|
|
54
|
+
if a displaced orphan start needs to be emitted alongside buffering a new start."""
|
|
55
|
+
if event.metadata is None:
|
|
56
|
+
# Defensive: SessionEvent defaults metadata to EventMetadata(), but a
|
|
57
|
+
# model_construct-built event may carry None. Normalize up front so the
|
|
58
|
+
# pairing/classification helpers below never raise — a raise here would
|
|
59
|
+
# push the raw event straight to sinks and bypass the exactly-once
|
|
60
|
+
# tool-call pairing guarantee the downstream governance stage relies on.
|
|
61
|
+
event = event.model_copy(update={"metadata": EventMetadata()})
|
|
62
|
+
if event.kind == EventKind.TOOL_CALL_STARTED:
|
|
63
|
+
event = self._classify(event)
|
|
64
|
+
event = self._set_visibility(event)
|
|
65
|
+
event = self._set_phase(event)
|
|
66
|
+
tool_call_id = _extract_tool_call_id(event)
|
|
67
|
+
if tool_call_id:
|
|
68
|
+
# Key pending starts by (session_id, tool_call_id): a single
|
|
69
|
+
# Enricher instance serves interleaved sessions, and tool_call_ids
|
|
70
|
+
# are only unique within a session. Keying on the id alone lets
|
|
71
|
+
# one session's start displace another's and lets a completion
|
|
72
|
+
# pair across sessions — double-scoring one real call.
|
|
73
|
+
key = (event.session_id, tool_call_id)
|
|
74
|
+
displaced = self._pending.pop(key, None)
|
|
75
|
+
self._pending[key] = event
|
|
76
|
+
if displaced is not None:
|
|
77
|
+
logger.warning(
|
|
78
|
+
"Duplicate TOOL_START for session_id=%s tool_call_id=%s; "
|
|
79
|
+
"emitting previous as orphan",
|
|
80
|
+
event.session_id,
|
|
81
|
+
tool_call_id,
|
|
82
|
+
)
|
|
83
|
+
orphan_metadata = displaced.metadata.model_copy(update={"duration_ms": None})
|
|
84
|
+
return [displaced.model_copy(update={"metadata": orphan_metadata})]
|
|
85
|
+
return None
|
|
86
|
+
return event
|
|
87
|
+
|
|
88
|
+
if event.kind == EventKind.TOOL_CALL_COMPLETED:
|
|
89
|
+
tool_call_id = _extract_tool_call_id(event)
|
|
90
|
+
start_event = (
|
|
91
|
+
self._pending.get((event.session_id, tool_call_id)) if tool_call_id else None
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if start_event is not None:
|
|
95
|
+
duration_ms = _compute_duration_ms(start_event.timestamp, event.timestamp)
|
|
96
|
+
# Merge payloads: start is base, complete overrides, but preserve
|
|
97
|
+
# start's _enrichment (classification/risk already computed on start)
|
|
98
|
+
merged_payload = {**start_event.payload, **event.payload}
|
|
99
|
+
start_enrichment = start_event.payload.get("_enrichment")
|
|
100
|
+
if isinstance(start_enrichment, dict):
|
|
101
|
+
complete_enrichment = event.payload.get("_enrichment")
|
|
102
|
+
if isinstance(complete_enrichment, dict):
|
|
103
|
+
merged_payload["_enrichment"] = {**start_enrichment, **complete_enrichment}
|
|
104
|
+
else:
|
|
105
|
+
merged_payload["_enrichment"] = start_enrichment
|
|
106
|
+
merged_metadata = _merge_metadata(start_event.metadata, event.metadata, duration_ms)
|
|
107
|
+
event = event.model_copy(
|
|
108
|
+
update={"payload": merged_payload, "metadata": merged_metadata}
|
|
109
|
+
)
|
|
110
|
+
del self._pending[(event.session_id, tool_call_id)]
|
|
111
|
+
else:
|
|
112
|
+
event = self._classify(event)
|
|
113
|
+
event = self._set_visibility(event)
|
|
114
|
+
|
|
115
|
+
event = self._set_phase(event)
|
|
116
|
+
return event
|
|
117
|
+
|
|
118
|
+
if event.kind == EventKind.SESSION_ENDED:
|
|
119
|
+
# Flush this session's still-buffered tool starts as orphans BEFORE the
|
|
120
|
+
# session-ended event. A governance stage finalizes and evicts session
|
|
121
|
+
# state on session.ended; without this, buffered unpaired starts would
|
|
122
|
+
# surface only at pipeline close (Enricher.flush) — i.e. after the
|
|
123
|
+
# session summary was already written — and be scored into a resurrected
|
|
124
|
+
# state that never gets finalized.
|
|
125
|
+
#
|
|
126
|
+
# This assumes session.ended is terminal for the session (the contract
|
|
127
|
+
# every supported adapter emits). A tool.call.completed arriving AFTER
|
|
128
|
+
# its own session.ended is a malformed stream: its start was already
|
|
129
|
+
# flushed+scored here, so the late completion would be observed a second
|
|
130
|
+
# time into a resurrected state. We do not carry per-session tombstones
|
|
131
|
+
# to dedup that case — it does not occur in well-formed input.
|
|
132
|
+
orphans = self._flush_session(event.session_id)
|
|
133
|
+
event = self._set_visibility(event)
|
|
134
|
+
event = self._set_phase(event)
|
|
135
|
+
return [*orphans, event] if orphans else event
|
|
136
|
+
|
|
137
|
+
# Non-tool events: set visibility and phase, pass through
|
|
138
|
+
event = self._set_visibility(event)
|
|
139
|
+
event = self._set_phase(event)
|
|
140
|
+
return event
|
|
141
|
+
|
|
142
|
+
def flush(self) -> list[SessionEvent]:
|
|
143
|
+
"""Emit any buffered events (unpaired tool_starts) with duration_ms=None.
|
|
144
|
+
Call at session end."""
|
|
145
|
+
buffered = list(self._pending.values())
|
|
146
|
+
result: list[SessionEvent] = []
|
|
147
|
+
for event in buffered:
|
|
148
|
+
new_metadata = event.metadata.model_copy(update={"duration_ms": None})
|
|
149
|
+
result.append(event.model_copy(update={"metadata": new_metadata}))
|
|
150
|
+
self._pending.clear()
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
def _flush_session(self, session_id: str) -> list[SessionEvent]:
|
|
154
|
+
"""Emit and remove this session's buffered unpaired tool-starts as orphans.
|
|
155
|
+
|
|
156
|
+
Like :meth:`flush` but scoped to a single session — used to drain pending
|
|
157
|
+
starts the instant that session ends, rather than waiting for pipeline
|
|
158
|
+
close, so a governance stage sees them before it finalizes the session."""
|
|
159
|
+
result: list[SessionEvent] = []
|
|
160
|
+
for key, event in list(self._pending.items()):
|
|
161
|
+
if event.session_id == session_id:
|
|
162
|
+
new_metadata = event.metadata.model_copy(update={"duration_ms": None})
|
|
163
|
+
result.append(event.model_copy(update={"metadata": new_metadata}))
|
|
164
|
+
del self._pending[key]
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
# --- Private helpers ---
|
|
168
|
+
|
|
169
|
+
def _classify(self, event: SessionEvent) -> SessionEvent:
|
|
170
|
+
"""Set metadata.classification from the tool name and payload.
|
|
171
|
+
|
|
172
|
+
For shell executor tools, performs deep tree-sitter classification of the
|
|
173
|
+
actual command rather than using the static shell entry.
|
|
174
|
+
After classification, refines scope based on file paths in the payload.
|
|
175
|
+
Also computes risk score for shell commands.
|
|
176
|
+
"""
|
|
177
|
+
tool_name = event.payload.get("tool_name", "")
|
|
178
|
+
if not tool_name:
|
|
179
|
+
return event
|
|
180
|
+
|
|
181
|
+
canonical = normalize_tool_name(tool_name, engine=self._engine)
|
|
182
|
+
|
|
183
|
+
is_shell = canonical == "shell"
|
|
184
|
+
if is_shell:
|
|
185
|
+
cls = self._classify_shell_command(event)
|
|
186
|
+
else:
|
|
187
|
+
cls = classify_tool(tool_name, self._custom_classifications, engine=self._engine)
|
|
188
|
+
|
|
189
|
+
# Refine scope from file paths in payload
|
|
190
|
+
cls = _refine_scope_from_payload(cls, event.payload)
|
|
191
|
+
|
|
192
|
+
new_metadata = event.metadata.model_copy(update={"classification": cls})
|
|
193
|
+
event = event.model_copy(update={"metadata": new_metadata})
|
|
194
|
+
|
|
195
|
+
# Risk scoring
|
|
196
|
+
if self._engine.risk_config is not None:
|
|
197
|
+
if is_shell:
|
|
198
|
+
event = self._assess_risk(event, cls)
|
|
199
|
+
else:
|
|
200
|
+
event = self._assess_tool_risk(event, cls)
|
|
201
|
+
|
|
202
|
+
return event
|
|
203
|
+
|
|
204
|
+
def _classify_shell_command(self, event: SessionEvent) -> Classification:
|
|
205
|
+
"""Deep-classify the actual shell command via the appropriate dialect classifier."""
|
|
206
|
+
arguments = event.payload.get("arguments", {})
|
|
207
|
+
command = ""
|
|
208
|
+
if isinstance(arguments, dict):
|
|
209
|
+
command = arguments.get("command", "") or arguments.get("cmd", "")
|
|
210
|
+
elif isinstance(arguments, str):
|
|
211
|
+
command = arguments
|
|
212
|
+
|
|
213
|
+
if not command:
|
|
214
|
+
return Classification(mechanism=CodingMechanism.PROCESS_SHELL, effect=None)
|
|
215
|
+
|
|
216
|
+
# Dispatch to dialect-specific classifier based on raw tool name
|
|
217
|
+
raw_tool = event.payload.get("tool_name", "").lower()
|
|
218
|
+
if raw_tool in ("powershell", "pwsh"):
|
|
219
|
+
return classify_powershell_command(command, engine=self._engine)
|
|
220
|
+
if raw_tool == "cmd":
|
|
221
|
+
return classify_cmd_command(command, engine=self._engine)
|
|
222
|
+
|
|
223
|
+
return classify_shell(command, engine=self._engine)
|
|
224
|
+
|
|
225
|
+
def _assess_risk(self, event: SessionEvent, cls: Classification) -> SessionEvent:
|
|
226
|
+
"""Compute risk score for a shell command and store in payload._enrichment."""
|
|
227
|
+
arguments = event.payload.get("arguments", {})
|
|
228
|
+
command = ""
|
|
229
|
+
if isinstance(arguments, dict):
|
|
230
|
+
command = arguments.get("command", "") or arguments.get("cmd", "")
|
|
231
|
+
elif isinstance(arguments, str):
|
|
232
|
+
command = arguments
|
|
233
|
+
|
|
234
|
+
if not command:
|
|
235
|
+
return event
|
|
236
|
+
|
|
237
|
+
risk = assess_risk(
|
|
238
|
+
classification=cls,
|
|
239
|
+
command=command,
|
|
240
|
+
engine=self._engine,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Store risk assessment in payload under _enrichment
|
|
244
|
+
enrichment_raw = event.payload.get("_enrichment")
|
|
245
|
+
enrichment = dict(enrichment_raw) if isinstance(enrichment_raw, dict) else {}
|
|
246
|
+
enrichment["risk"] = {
|
|
247
|
+
"score": risk.score,
|
|
248
|
+
"level": risk.level,
|
|
249
|
+
"confidence": risk.confidence,
|
|
250
|
+
"factors": list(risk.factors),
|
|
251
|
+
"mitre": list(risk.mitre),
|
|
252
|
+
"version": risk.version,
|
|
253
|
+
}
|
|
254
|
+
new_payload = {**event.payload, "_enrichment": enrichment}
|
|
255
|
+
return event.model_copy(update={"payload": new_payload})
|
|
256
|
+
|
|
257
|
+
def _assess_tool_risk(self, event: SessionEvent, cls: Classification) -> SessionEvent:
|
|
258
|
+
"""Compute risk score for a native/MCP tool and store in payload._enrichment."""
|
|
259
|
+
# Extract file targets from payload
|
|
260
|
+
targets = _extract_targets_from_payload(event.payload)
|
|
261
|
+
|
|
262
|
+
risk = assess_tool_risk(
|
|
263
|
+
classification=cls,
|
|
264
|
+
engine=self._engine,
|
|
265
|
+
targets=targets or None,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
enrichment_raw = event.payload.get("_enrichment")
|
|
269
|
+
enrichment = dict(enrichment_raw) if isinstance(enrichment_raw, dict) else {}
|
|
270
|
+
enrichment["risk"] = {
|
|
271
|
+
"score": risk.score,
|
|
272
|
+
"level": risk.level,
|
|
273
|
+
"confidence": risk.confidence,
|
|
274
|
+
"factors": list(risk.factors),
|
|
275
|
+
"mitre": list(risk.mitre),
|
|
276
|
+
"version": risk.version,
|
|
277
|
+
}
|
|
278
|
+
new_payload = {**event.payload, "_enrichment": enrichment}
|
|
279
|
+
return event.model_copy(update={"payload": new_payload})
|
|
280
|
+
|
|
281
|
+
def _set_visibility(self, event: SessionEvent) -> SessionEvent:
|
|
282
|
+
"""Set metadata.visibility based on event kind and classification."""
|
|
283
|
+
visibility = Visibility.VISIBLE
|
|
284
|
+
|
|
285
|
+
if event.kind in (EventKind.SESSION_STARTED, EventKind.SESSION_ENDED):
|
|
286
|
+
visibility = Visibility.SYSTEM
|
|
287
|
+
elif event.metadata.classification is not None:
|
|
288
|
+
cls: Classification = event.metadata.classification
|
|
289
|
+
if cls.mechanism.startswith(("communication.system", "communication.internal")):
|
|
290
|
+
visibility = Visibility.SYSTEM
|
|
291
|
+
|
|
292
|
+
if visibility != event.metadata.visibility:
|
|
293
|
+
new_metadata = event.metadata.model_copy(update={"visibility": visibility})
|
|
294
|
+
return event.model_copy(update={"metadata": new_metadata})
|
|
295
|
+
return event
|
|
296
|
+
|
|
297
|
+
def _set_phase(self, event: SessionEvent) -> SessionEvent:
|
|
298
|
+
"""Set metadata.phases based on Classification dimensions."""
|
|
299
|
+
phases = self._detect_phases(event)
|
|
300
|
+
if phases != event.metadata.phases:
|
|
301
|
+
new_metadata = event.metadata.model_copy(update={"phases": phases})
|
|
302
|
+
return event.model_copy(update={"metadata": new_metadata})
|
|
303
|
+
return event
|
|
304
|
+
|
|
305
|
+
def _detect_phases(self, event: SessionEvent) -> frozenset[str]:
|
|
306
|
+
"""Determine the phase(s) for an event from its Classification."""
|
|
307
|
+
if event.kind in (EventKind.MESSAGE_USER, EventKind.MESSAGE_ASSISTANT):
|
|
308
|
+
return frozenset({Phase.PLANNING})
|
|
309
|
+
|
|
310
|
+
cls: Classification | None = event.metadata.classification
|
|
311
|
+
|
|
312
|
+
if cls is None:
|
|
313
|
+
if event.kind in (EventKind.TOOL_CALL_STARTED, EventKind.TOOL_CALL_COMPLETED):
|
|
314
|
+
return frozenset({Phase.IMPLEMENTATION})
|
|
315
|
+
return frozenset({Phase.PLANNING})
|
|
316
|
+
|
|
317
|
+
return _phases_from_classification(cls)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _phases_from_classification(cls: Classification) -> frozenset[str]:
|
|
321
|
+
"""Derive phases from a Classification.
|
|
322
|
+
|
|
323
|
+
If the classification has a phase_map (built per-command), use it directly.
|
|
324
|
+
Otherwise, derive phases from the aggregate action/role dimensions.
|
|
325
|
+
"""
|
|
326
|
+
if cls.phase_map:
|
|
327
|
+
return frozenset(seg.phase for seg in cls.phase_map)
|
|
328
|
+
|
|
329
|
+
# Rule table: (predicate, phase) — evaluated in order, all matching rules fire
|
|
330
|
+
phases: set[str] = set()
|
|
331
|
+
|
|
332
|
+
_PHASE_RULES: list[tuple[str, str]] = [
|
|
333
|
+
# (action_or_check, phase)
|
|
334
|
+
("validate", Phase.VERIFICATION),
|
|
335
|
+
("deliver", Phase.REVIEW),
|
|
336
|
+
("retrieve", Phase.EXPLORATION),
|
|
337
|
+
("analyze", Phase.EXPLORATION),
|
|
338
|
+
("configure", Phase.IMPLEMENTATION),
|
|
339
|
+
("execute", Phase.IMPLEMENTATION),
|
|
340
|
+
]
|
|
341
|
+
for action, phase in _PHASE_RULES:
|
|
342
|
+
if cls.has_action(action):
|
|
343
|
+
phases.add(phase)
|
|
344
|
+
|
|
345
|
+
# VCS persist → review (not implementation)
|
|
346
|
+
if cls.has_role("persistence.version_control") and (
|
|
347
|
+
cls.has_action("persist") or cls.has_action("deliver")
|
|
348
|
+
):
|
|
349
|
+
phases.add(Phase.REVIEW)
|
|
350
|
+
elif cls.has_action("modify") or cls.has_action("persist"):
|
|
351
|
+
phases.add(Phase.IMPLEMENTATION)
|
|
352
|
+
|
|
353
|
+
# Mechanism-based rules
|
|
354
|
+
if cls.mechanism.startswith("communication"):
|
|
355
|
+
phases.add(Phase.PLANNING)
|
|
356
|
+
elif cls.mechanism.startswith("delegation"):
|
|
357
|
+
phases.add(Phase.IMPLEMENTATION)
|
|
358
|
+
elif cls.mechanism == "filesystem" and cls.effect == "read_only":
|
|
359
|
+
phases.add(Phase.EXPLORATION)
|
|
360
|
+
|
|
361
|
+
return frozenset(phases) if phases else frozenset({Phase.IMPLEMENTATION})
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ── Scope inference from file paths ──
|
|
365
|
+
|
|
366
|
+
# Path segment patterns → scope (matched against normalized path segments)
|
|
367
|
+
_TEST_SEGMENTS = frozenset({"tests", "test", "spec", "specs", "__tests__", "__test__"})
|
|
368
|
+
_TEST_FILE_PATTERNS = ("_test.", "test_", ".test.", ".spec.")
|
|
369
|
+
_DOC_SEGMENTS = frozenset({"docs", "doc", "documentation"})
|
|
370
|
+
_CI_FILES = frozenset(
|
|
371
|
+
{
|
|
372
|
+
"jenkinsfile",
|
|
373
|
+
".travis.yml",
|
|
374
|
+
".circleci",
|
|
375
|
+
"azure-pipelines.yml",
|
|
376
|
+
"bitbucket-pipelines.yml",
|
|
377
|
+
"cloudbuild.yaml",
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
_DEP_FILES = frozenset(
|
|
382
|
+
{
|
|
383
|
+
"package.json",
|
|
384
|
+
"package-lock.json",
|
|
385
|
+
"yarn.lock",
|
|
386
|
+
"pnpm-lock.yaml",
|
|
387
|
+
"requirements.txt",
|
|
388
|
+
"setup.py",
|
|
389
|
+
"setup.cfg",
|
|
390
|
+
"pyproject.toml",
|
|
391
|
+
"poetry.lock",
|
|
392
|
+
"cargo.toml",
|
|
393
|
+
"cargo.lock",
|
|
394
|
+
"go.mod",
|
|
395
|
+
"go.sum",
|
|
396
|
+
"gemfile",
|
|
397
|
+
"gemfile.lock",
|
|
398
|
+
"composer.json",
|
|
399
|
+
"composer.lock",
|
|
400
|
+
"pom.xml",
|
|
401
|
+
"build.gradle",
|
|
402
|
+
}
|
|
403
|
+
)
|
|
404
|
+
_ENV_FILES = frozenset({".env", ".envrc", ".env.local", ".env.development", ".env.production"})
|
|
405
|
+
_INFRA_EXTENSIONS = (".tf", ".tfvars", ".hcl")
|
|
406
|
+
_INFRA_DIRS = frozenset({"helm", "charts", "k8s", "kubernetes", "terraform", "infra"})
|
|
407
|
+
_CONTAINER_FILES = frozenset({"docker-compose.yml", "docker-compose.yaml", ".dockerignore"})
|
|
408
|
+
_DOC_FILES = frozenset({"readme.md", "contributing.md", "changelog.md", "license.md"})
|
|
409
|
+
_PAYLOAD_PATH_KEYS = ("path", "file_path", "file", "filename")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _infer_scope_from_path(path: str) -> str | None:
|
|
413
|
+
"""Infer a CodingScope from a file path. Returns None if no pattern matches."""
|
|
414
|
+
if not path:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
normalized = path.replace("\\", "/").lower()
|
|
418
|
+
segments = normalized.split("/")
|
|
419
|
+
basename = segments[-1] if segments else ""
|
|
420
|
+
|
|
421
|
+
if _TEST_SEGMENTS.intersection(segments):
|
|
422
|
+
return CodingScope.TEST_CODE
|
|
423
|
+
if any(p in basename for p in _TEST_FILE_PATTERNS):
|
|
424
|
+
return CodingScope.TEST_CODE
|
|
425
|
+
|
|
426
|
+
if ".github" in segments and ("workflows" in segments or basename in ("dependabot.yml",)):
|
|
427
|
+
return CodingScope.CI_CD_CONFIG
|
|
428
|
+
if basename in _CI_FILES:
|
|
429
|
+
return CodingScope.CI_CD_CONFIG
|
|
430
|
+
|
|
431
|
+
if basename.startswith("dockerfile") or basename in _CONTAINER_FILES:
|
|
432
|
+
return CodingScope.CONTAINER_IMAGE
|
|
433
|
+
|
|
434
|
+
if _DOC_SEGMENTS.intersection(segments):
|
|
435
|
+
return CodingScope.DOCUMENTATION
|
|
436
|
+
if basename in _DOC_FILES:
|
|
437
|
+
return CodingScope.DOCUMENTATION
|
|
438
|
+
|
|
439
|
+
if basename in _DEP_FILES:
|
|
440
|
+
return CodingScope.DEPENDENCY
|
|
441
|
+
|
|
442
|
+
if basename in _ENV_FILES:
|
|
443
|
+
return CodingScope.ENVIRONMENT
|
|
444
|
+
|
|
445
|
+
if any(basename.endswith(ext) for ext in _INFRA_EXTENSIONS):
|
|
446
|
+
return CodingScope.INFRASTRUCTURE
|
|
447
|
+
if _INFRA_DIRS.intersection(segments):
|
|
448
|
+
return CodingScope.INFRASTRUCTURE
|
|
449
|
+
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _extract_path_from_payload(payload: dict) -> str:
|
|
454
|
+
"""Extract the first file path string from common payload keys."""
|
|
455
|
+
for key in _PAYLOAD_PATH_KEYS:
|
|
456
|
+
val = payload.get(key, "")
|
|
457
|
+
if isinstance(val, str) and val:
|
|
458
|
+
return val
|
|
459
|
+
args = payload.get("arguments", {})
|
|
460
|
+
if isinstance(args, dict):
|
|
461
|
+
for key in _PAYLOAD_PATH_KEYS:
|
|
462
|
+
val = args.get(key, "")
|
|
463
|
+
if isinstance(val, str) and val:
|
|
464
|
+
return val
|
|
465
|
+
return ""
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _refine_scope_from_payload(cls: Classification, payload: dict) -> Classification:
|
|
469
|
+
"""Refine classification scope based on file paths in the event payload.
|
|
470
|
+
|
|
471
|
+
Only applies to filesystem-mechanism tools. Updates both top-level scope
|
|
472
|
+
and phase_map segment scopes for consistency.
|
|
473
|
+
"""
|
|
474
|
+
if not cls.mechanism.startswith("filesystem"):
|
|
475
|
+
return cls
|
|
476
|
+
|
|
477
|
+
file_path = _extract_path_from_payload(payload)
|
|
478
|
+
if not file_path:
|
|
479
|
+
return cls
|
|
480
|
+
|
|
481
|
+
inferred = _infer_scope_from_path(file_path)
|
|
482
|
+
if inferred is None:
|
|
483
|
+
return cls
|
|
484
|
+
|
|
485
|
+
# Don't override if the inferred scope is already present
|
|
486
|
+
if inferred in cls.scope:
|
|
487
|
+
return cls
|
|
488
|
+
|
|
489
|
+
# Build new scope (replace default source_code with inferred, or add)
|
|
490
|
+
new_scope = set(cls.scope)
|
|
491
|
+
if CodingScope.SOURCE_CODE in new_scope:
|
|
492
|
+
new_scope.discard(CodingScope.SOURCE_CODE)
|
|
493
|
+
new_scope.add(inferred)
|
|
494
|
+
frozen_scope = frozenset(new_scope)
|
|
495
|
+
|
|
496
|
+
# Update phase_map segments consistently
|
|
497
|
+
new_phase_map = tuple(
|
|
498
|
+
PhaseSegment(
|
|
499
|
+
phase=seg.phase,
|
|
500
|
+
actions=seg.actions,
|
|
501
|
+
scopes=(seg.scopes - {CodingScope.SOURCE_CODE}) | {inferred}
|
|
502
|
+
if CodingScope.SOURCE_CODE in seg.scopes
|
|
503
|
+
else seg.scopes | {inferred},
|
|
504
|
+
roles=seg.roles,
|
|
505
|
+
)
|
|
506
|
+
for seg in cls.phase_map
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return Classification(
|
|
510
|
+
mechanism=cls.mechanism,
|
|
511
|
+
effect=cls.effect,
|
|
512
|
+
scope=frozen_scope,
|
|
513
|
+
role=cls.role,
|
|
514
|
+
action=cls.action,
|
|
515
|
+
capability=cls.capability,
|
|
516
|
+
structure=cls.structure,
|
|
517
|
+
shell_dialect=cls.shell_dialect,
|
|
518
|
+
binaries=cls.binaries,
|
|
519
|
+
phase_map=new_phase_map,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _compute_duration_ms(start: datetime, end: datetime) -> float:
|
|
524
|
+
"""Compute duration in milliseconds between two timestamps."""
|
|
525
|
+
delta = (end - start).total_seconds() * 1000.0
|
|
526
|
+
return max(delta, 0.0)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _extract_tool_call_id(event: SessionEvent) -> str | None:
|
|
530
|
+
"""Extract and validate tool_call_id from event payload.
|
|
531
|
+
Returns None if missing, empty, or non-string."""
|
|
532
|
+
value = event.payload.get("tool_call_id")
|
|
533
|
+
if isinstance(value, str) and value:
|
|
534
|
+
return value
|
|
535
|
+
if value is not None and not isinstance(value, str):
|
|
536
|
+
logger.debug("Ignoring non-string tool_call_id: %r", value)
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _extract_targets_from_payload(payload: dict) -> list[str]:
|
|
541
|
+
"""Extract file path targets from event payload for risk scoring."""
|
|
542
|
+
targets: list[str] = []
|
|
543
|
+
primary = _extract_path_from_payload(payload)
|
|
544
|
+
if primary:
|
|
545
|
+
targets.append(primary)
|
|
546
|
+
# Also pick up pattern/glob from arguments
|
|
547
|
+
args = payload.get("arguments", {})
|
|
548
|
+
if isinstance(args, dict):
|
|
549
|
+
for key in ("pattern", "glob"):
|
|
550
|
+
val = args.get(key, "")
|
|
551
|
+
if isinstance(val, str) and val and val not in targets:
|
|
552
|
+
targets.append(val)
|
|
553
|
+
return targets
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _merge_metadata(
|
|
557
|
+
start: EventMetadata, complete: EventMetadata, duration_ms: float
|
|
558
|
+
) -> EventMetadata:
|
|
559
|
+
"""Merge metadata from start and complete events. Start is the base;
|
|
560
|
+
non-None complete fields override. Duration is always set from computation.
|
|
561
|
+
Classification and visibility come from start (authoritative)."""
|
|
562
|
+
updates: dict[str, object] = {"duration_ms": duration_ms}
|
|
563
|
+
_start_authoritative = {"classification", "visibility"}
|
|
564
|
+
for field_name in EventMetadata.model_fields:
|
|
565
|
+
if field_name == "duration_ms":
|
|
566
|
+
continue
|
|
567
|
+
start_val = getattr(start, field_name)
|
|
568
|
+
complete_val = getattr(complete, field_name)
|
|
569
|
+
if field_name in _start_authoritative:
|
|
570
|
+
updates[field_name] = start_val if start_val is not None else complete_val
|
|
571
|
+
else:
|
|
572
|
+
if complete_val is not None:
|
|
573
|
+
updates[field_name] = complete_val
|
|
574
|
+
elif start_val is not None:
|
|
575
|
+
updates[field_name] = start_val
|
|
576
|
+
return EventMetadata(**updates)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Human-readable event formatting for terminal output, reports, and sinks."""
|
|
2
|
+
|
|
3
|
+
from traceforge.formatting.budget import format_budget_summary, format_session_summary
|
|
4
|
+
from traceforge.formatting.density import Density, format_event, format_trace
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Density",
|
|
8
|
+
"format_budget_summary",
|
|
9
|
+
"format_event",
|
|
10
|
+
"format_session_summary",
|
|
11
|
+
"format_trace",
|
|
12
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Budget and session summary formatting utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
|
|
7
|
+
from traceforge.types import SessionEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_budget_summary(budget_snapshot: dict) -> str:
|
|
11
|
+
"""Format budget consumption as a compact table string.
|
|
12
|
+
|
|
13
|
+
Expects a dict with keys like:
|
|
14
|
+
total_tool_calls, max_tool_calls,
|
|
15
|
+
by_effect (dict of effect → count),
|
|
16
|
+
by_capability (dict of capability → count),
|
|
17
|
+
by_scope (dict of scope → count).
|
|
18
|
+
"""
|
|
19
|
+
lines: list[str] = []
|
|
20
|
+
lines.append("┌─ Budget Summary ────────────────────┐")
|
|
21
|
+
|
|
22
|
+
total = budget_snapshot.get("total_tool_calls", 0)
|
|
23
|
+
max_calls = budget_snapshot.get("max_tool_calls")
|
|
24
|
+
if max_calls is not None:
|
|
25
|
+
pct = (total / max_calls * 100) if max_calls > 0 else 0
|
|
26
|
+
lines.append(f"│ Tool calls: {total}/{max_calls} ({pct:.0f}%)")
|
|
27
|
+
else:
|
|
28
|
+
lines.append(f"│ Tool calls: {total} (no limit)")
|
|
29
|
+
|
|
30
|
+
for category in ("by_effect", "by_capability", "by_scope"):
|
|
31
|
+
data = budget_snapshot.get(category)
|
|
32
|
+
if data:
|
|
33
|
+
label = category.replace("by_", "").title()
|
|
34
|
+
entries = ", ".join(f"{k}={v}" for k, v in sorted(data.items()))
|
|
35
|
+
lines.append(f"│ {label}: {entries}")
|
|
36
|
+
|
|
37
|
+
lines.append("└─────────────────────────────────────┘")
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_session_summary(
|
|
42
|
+
events: list[SessionEvent],
|
|
43
|
+
*,
|
|
44
|
+
include_risk: bool = True,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Aggregate stats: event count by kind, risk distribution, top tools.
|
|
47
|
+
|
|
48
|
+
Returns a multi-line summary string suitable for terminal display.
|
|
49
|
+
"""
|
|
50
|
+
if not events:
|
|
51
|
+
return "No events recorded."
|
|
52
|
+
|
|
53
|
+
# Count by kind
|
|
54
|
+
kind_counts: Counter[str] = Counter()
|
|
55
|
+
risk_counts: Counter[str] = Counter()
|
|
56
|
+
tool_counts: Counter[str] = Counter()
|
|
57
|
+
|
|
58
|
+
for event in events:
|
|
59
|
+
kind_counts[event.kind] += 1
|
|
60
|
+
|
|
61
|
+
if event.metadata:
|
|
62
|
+
if event.metadata.tool_display:
|
|
63
|
+
tool_counts[event.metadata.tool_display] += 1
|
|
64
|
+
if include_risk and event.metadata.classification:
|
|
65
|
+
cls = event.metadata.classification
|
|
66
|
+
if hasattr(cls, "risk_band") and cls.risk_band:
|
|
67
|
+
risk_counts[str(cls.risk_band)] += 1
|
|
68
|
+
|
|
69
|
+
lines: list[str] = []
|
|
70
|
+
lines.append(f"Session: {events[0].session_id} ({len(events)} events)")
|
|
71
|
+
lines.append("")
|
|
72
|
+
|
|
73
|
+
# Event kinds
|
|
74
|
+
lines.append("Events by kind:")
|
|
75
|
+
for kind, count in kind_counts.most_common(10):
|
|
76
|
+
lines.append(f" {kind:<35} {count:>4}")
|
|
77
|
+
|
|
78
|
+
# Top tools
|
|
79
|
+
if tool_counts:
|
|
80
|
+
lines.append("")
|
|
81
|
+
lines.append("Top tools:")
|
|
82
|
+
for tool, count in tool_counts.most_common(5):
|
|
83
|
+
lines.append(f" {tool:<35} {count:>4}")
|
|
84
|
+
|
|
85
|
+
# Risk distribution
|
|
86
|
+
if include_risk and risk_counts:
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append("Risk distribution:")
|
|
89
|
+
for risk, count in risk_counts.most_common():
|
|
90
|
+
lines.append(f" {risk:<35} {count:>4}")
|
|
91
|
+
|
|
92
|
+
return "\n".join(lines)
|