cmdscope 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.
- cmdscope/__init__.py +12 -0
- cmdscope/__main__.py +8 -0
- cmdscope/analysis/__init__.py +1 -0
- cmdscope/analysis/comparison.py +134 -0
- cmdscope/analysis/summary.py +37 -0
- cmdscope/cli.py +555 -0
- cmdscope/collectors/__init__.py +1 -0
- cmdscope/collectors/filesystem.py +370 -0
- cmdscope/collectors/network.py +196 -0
- cmdscope/collectors/processes.py +365 -0
- cmdscope/config.py +461 -0
- cmdscope/core/__init__.py +1 -0
- cmdscope/core/collector.py +48 -0
- cmdscope/core/engine.py +514 -0
- cmdscope/core/events.py +113 -0
- cmdscope/core/ignore.py +56 -0
- cmdscope/core/registry.py +33 -0
- cmdscope/core/session.py +81 -0
- cmdscope/diagnostics.py +183 -0
- cmdscope/errors.py +36 -0
- cmdscope/platforms/__init__.py +57 -0
- cmdscope/platforms/base.py +140 -0
- cmdscope/platforms/linux.py +28 -0
- cmdscope/platforms/macos.py +26 -0
- cmdscope/platforms/windows.py +28 -0
- cmdscope/privacy.py +143 -0
- cmdscope/py.typed +0 -0
- cmdscope/reports/__init__.py +1 -0
- cmdscope/reports/html.py +279 -0
- cmdscope/reports/json_report.py +133 -0
- cmdscope/reports/terminal.py +429 -0
- cmdscope/storage/__init__.py +6 -0
- cmdscope/storage/sqlite.py +469 -0
- cmdscope/storage/writer.py +105 -0
- cmdscope-0.1.0.dist-info/METADATA +353 -0
- cmdscope-0.1.0.dist-info/RECORD +40 -0
- cmdscope-0.1.0.dist-info/WHEEL +5 -0
- cmdscope-0.1.0.dist-info/entry_points.txt +2 -0
- cmdscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- cmdscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Plain-text terminal reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
from cmdscope.analysis.comparison import ComparisonItem, ComparisonResult
|
|
12
|
+
from cmdscope.core.events import CollectorState, EventKind, StoredEvent
|
|
13
|
+
from cmdscope.core.session import Session, SessionSummary
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
RULE = "=" * 70
|
|
18
|
+
_BOLD_CYAN = "1;36"
|
|
19
|
+
_BOLD = "1"
|
|
20
|
+
_GREEN = "32"
|
|
21
|
+
_YELLOW = "33"
|
|
22
|
+
_RED = "31"
|
|
23
|
+
_DIM = "2"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _style(text: str, code: str, enabled: bool) -> str:
|
|
27
|
+
if not enabled:
|
|
28
|
+
return text
|
|
29
|
+
return f"\033[{code}m{text}\033[0m"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _heading(text: str, color: bool) -> str:
|
|
33
|
+
return _style(text, _BOLD_CYAN, color)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fmt_time(value: datetime | None) -> str:
|
|
37
|
+
if value is None:
|
|
38
|
+
return "-"
|
|
39
|
+
return value.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fmt_duration(value: float | None) -> str:
|
|
43
|
+
if value is None:
|
|
44
|
+
return "-"
|
|
45
|
+
if value < 1:
|
|
46
|
+
return f"{value * 1000:.0f} ms"
|
|
47
|
+
return f"{value:.2f} s"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fmt_size(value: object) -> str:
|
|
51
|
+
if not isinstance(value, int):
|
|
52
|
+
return "-"
|
|
53
|
+
size = float(value)
|
|
54
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
55
|
+
if size < 1024 or unit == "TB":
|
|
56
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
57
|
+
size /= 1024
|
|
58
|
+
return f"{size:.1f} TB"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _short_path(path: str, cwd: str) -> str:
|
|
62
|
+
try:
|
|
63
|
+
relative = os.path.relpath(path, cwd)
|
|
64
|
+
except ValueError:
|
|
65
|
+
relative = path
|
|
66
|
+
if relative.startswith(".."):
|
|
67
|
+
relative = path
|
|
68
|
+
if len(relative) > 110:
|
|
69
|
+
relative = "..." + relative[-107:]
|
|
70
|
+
return relative
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _limited(
|
|
74
|
+
items: Sequence[T],
|
|
75
|
+
limit: int | None,
|
|
76
|
+
) -> tuple[list[T], int]:
|
|
77
|
+
if limit is None:
|
|
78
|
+
return list(items), 0
|
|
79
|
+
return list(items[:limit]), max(0, len(items) - limit)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _status_label(state: CollectorState) -> str:
|
|
83
|
+
return str(state)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _event_symbol(kind: EventKind) -> str:
|
|
87
|
+
return {
|
|
88
|
+
EventKind.FILESYSTEM_CREATED: "+",
|
|
89
|
+
EventKind.FILESYSTEM_MODIFIED: "~",
|
|
90
|
+
EventKind.FILESYSTEM_DELETED: "-",
|
|
91
|
+
}.get(kind, "?")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _event_color(kind: EventKind) -> str:
|
|
95
|
+
return {
|
|
96
|
+
EventKind.FILESYSTEM_CREATED: _GREEN,
|
|
97
|
+
EventKind.FILESYSTEM_MODIFIED: _YELLOW,
|
|
98
|
+
EventKind.FILESYSTEM_DELETED: _RED,
|
|
99
|
+
}.get(kind, _DIM)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _filesystem_lines(
|
|
103
|
+
events: list[StoredEvent],
|
|
104
|
+
*,
|
|
105
|
+
cwd: str,
|
|
106
|
+
limit: int | None,
|
|
107
|
+
color: bool,
|
|
108
|
+
) -> list[str]:
|
|
109
|
+
if not events:
|
|
110
|
+
return [" (no filesystem events observed)"]
|
|
111
|
+
selected, hidden = _limited(events, limit)
|
|
112
|
+
lines = []
|
|
113
|
+
for event in selected:
|
|
114
|
+
payload = event.payload
|
|
115
|
+
path = str(payload.get("path", ""))
|
|
116
|
+
is_dir = bool(payload.get("is_dir", False))
|
|
117
|
+
display = _short_path(path, cwd) + ("/" if is_dir else "")
|
|
118
|
+
size = "" if is_dir else f" ({_fmt_size(payload.get('size'))})"
|
|
119
|
+
symbol = _style(_event_symbol(event.kind), _event_color(event.kind), color)
|
|
120
|
+
lines.append(f" {symbol} {display}{size}")
|
|
121
|
+
if hidden:
|
|
122
|
+
lines.append(f" ... {hidden} more filesystem events (use --all to show)")
|
|
123
|
+
return lines
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _process_lines(events: list[StoredEvent], *, limit: int | None) -> list[str]:
|
|
127
|
+
if not events:
|
|
128
|
+
return [" (no process events observed)"]
|
|
129
|
+
selected, hidden = _limited(events, limit)
|
|
130
|
+
lines = []
|
|
131
|
+
for event in selected:
|
|
132
|
+
payload = event.payload
|
|
133
|
+
pid = payload.get("pid", "?")
|
|
134
|
+
name = payload.get("name", "unknown")
|
|
135
|
+
command = str(payload.get("command", "") or name)
|
|
136
|
+
if len(command) > 100:
|
|
137
|
+
command = command[:97] + "..."
|
|
138
|
+
if event.kind == EventKind.PROCESS_STARTED:
|
|
139
|
+
lines.append(f" [pid {pid}] started: {command}")
|
|
140
|
+
elif event.kind == EventKind.PROCESS_FINISHED:
|
|
141
|
+
reason = payload.get("reason", "finished")
|
|
142
|
+
lines.append(f" [pid {pid}] finished: {name} ({reason})")
|
|
143
|
+
else:
|
|
144
|
+
lines.append(f" [pid {pid}] still running: {command}")
|
|
145
|
+
if hidden:
|
|
146
|
+
lines.append(f" ... {hidden} more process events (use --all to show)")
|
|
147
|
+
return lines
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _network_lines(events: list[StoredEvent], *, limit: int | None) -> list[str]:
|
|
151
|
+
if not events:
|
|
152
|
+
return [" (no network endpoints observed)"]
|
|
153
|
+
selected, hidden = _limited(events, limit)
|
|
154
|
+
lines = []
|
|
155
|
+
for event in selected:
|
|
156
|
+
payload = event.payload
|
|
157
|
+
protocol = payload.get("protocol", "TCP")
|
|
158
|
+
status = payload.get("status", "UNKNOWN")
|
|
159
|
+
local = payload.get("local_address") or "-"
|
|
160
|
+
remote = payload.get("remote_address") or "listening"
|
|
161
|
+
process = payload.get("process_name", "unknown")
|
|
162
|
+
pid = payload.get("pid", "?")
|
|
163
|
+
lines.append(f" [pid {pid}] {process}: {local} -> {remote} ({protocol}, {status})")
|
|
164
|
+
if hidden:
|
|
165
|
+
lines.append(f" ... {hidden} more network events (use --all to show)")
|
|
166
|
+
return lines
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _environment_lines(session: Session) -> list[str]:
|
|
170
|
+
diff = session.env_diff
|
|
171
|
+
added = diff.get("added")
|
|
172
|
+
removed = diff.get("removed")
|
|
173
|
+
changed = diff.get("changed")
|
|
174
|
+
lines: list[str] = []
|
|
175
|
+
if isinstance(added, dict):
|
|
176
|
+
for added_key in sorted(added):
|
|
177
|
+
lines.append(f" + {added_key}={_truncate_value(added[added_key])}")
|
|
178
|
+
if isinstance(removed, list):
|
|
179
|
+
for removed_key in removed:
|
|
180
|
+
lines.append(f" - {removed_key!s}")
|
|
181
|
+
if isinstance(changed, dict):
|
|
182
|
+
for changed_key in sorted(changed):
|
|
183
|
+
detail = changed[changed_key]
|
|
184
|
+
if isinstance(detail, dict):
|
|
185
|
+
before = _truncate_value(detail.get("before"))
|
|
186
|
+
after = _truncate_value(detail.get("after"))
|
|
187
|
+
lines.append(f" ~ {changed_key}: {before} -> {after}")
|
|
188
|
+
else:
|
|
189
|
+
lines.append(f" ~ {changed_key}")
|
|
190
|
+
return lines or [" (no initial environment differences; runtime mutations are not observable)"]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _truncate_value(value: object, limit: int = 80) -> str:
|
|
194
|
+
text = str(value)
|
|
195
|
+
if len(text) > limit:
|
|
196
|
+
return text[: limit - 3] + "..."
|
|
197
|
+
return text
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def render_session_report(
|
|
201
|
+
session: Session,
|
|
202
|
+
summary: SessionSummary,
|
|
203
|
+
events: Sequence[StoredEvent],
|
|
204
|
+
*,
|
|
205
|
+
limit: int | None = 50,
|
|
206
|
+
include_environment: bool = False,
|
|
207
|
+
color: bool = False,
|
|
208
|
+
) -> str:
|
|
209
|
+
"""Render a complete, human-readable report for one session."""
|
|
210
|
+
|
|
211
|
+
started = session.started_at or session.created_at
|
|
212
|
+
watch_roots = session.options.get("watch_roots")
|
|
213
|
+
if isinstance(watch_roots, list):
|
|
214
|
+
roots_text = ", ".join(str(root) for root in watch_roots)
|
|
215
|
+
else:
|
|
216
|
+
roots_text = "-"
|
|
217
|
+
|
|
218
|
+
lines = [
|
|
219
|
+
_heading("CMDSCOPE - EXECUTION REPORT", color),
|
|
220
|
+
RULE,
|
|
221
|
+
f"Session: {session.ref}",
|
|
222
|
+
f"Command: {session.command_display or '(unknown)'}",
|
|
223
|
+
f"Directory: {session.cwd}",
|
|
224
|
+
f"Platform: {session.platform_name}",
|
|
225
|
+
f"Started: {_fmt_time(started)}",
|
|
226
|
+
f"Duration: {_fmt_duration(session.duration_s)}",
|
|
227
|
+
f"Exit status: {session.exit_code if session.exit_code is not None else '-'}",
|
|
228
|
+
f"Status: {session.status}",
|
|
229
|
+
]
|
|
230
|
+
if session.interrupted:
|
|
231
|
+
lines.append("Interrupted: yes")
|
|
232
|
+
if session.error:
|
|
233
|
+
lines.append(f"Error: {session.error}")
|
|
234
|
+
lines.append("")
|
|
235
|
+
|
|
236
|
+
lines.append(_heading("FILESYSTEM", color))
|
|
237
|
+
lines.append(f" Watch roots: {roots_text}")
|
|
238
|
+
lines.append(f" Created: {summary.filesystem_created}")
|
|
239
|
+
lines.append(f" Modified: {summary.filesystem_modified}")
|
|
240
|
+
lines.append(f" Deleted: {summary.filesystem_deleted}")
|
|
241
|
+
filesystem_events = [
|
|
242
|
+
event
|
|
243
|
+
for event in events
|
|
244
|
+
if event.kind
|
|
245
|
+
in {
|
|
246
|
+
EventKind.FILESYSTEM_CREATED,
|
|
247
|
+
EventKind.FILESYSTEM_MODIFIED,
|
|
248
|
+
EventKind.FILESYSTEM_DELETED,
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
lines.extend(_filesystem_lines(filesystem_events, cwd=session.cwd, limit=limit, color=color))
|
|
252
|
+
lines.append("")
|
|
253
|
+
|
|
254
|
+
lines.append(_heading("PROCESSES", color))
|
|
255
|
+
lines.append(f" Started: {summary.processes_started}")
|
|
256
|
+
lines.append(f" Finished: {summary.processes_finished}")
|
|
257
|
+
lines.append(f" Still running: {summary.processes_still_running}")
|
|
258
|
+
process_events = [
|
|
259
|
+
event
|
|
260
|
+
for event in events
|
|
261
|
+
if event.kind
|
|
262
|
+
in {
|
|
263
|
+
EventKind.PROCESS_STARTED,
|
|
264
|
+
EventKind.PROCESS_FINISHED,
|
|
265
|
+
EventKind.PROCESS_STILL_RUNNING,
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
lines.extend(_process_lines(process_events, limit=limit))
|
|
269
|
+
lines.append("")
|
|
270
|
+
|
|
271
|
+
lines.append(_heading("NETWORK", color))
|
|
272
|
+
lines.append(f" Connections observed: {summary.network_connections}")
|
|
273
|
+
network_events = [event for event in events if event.kind == EventKind.NETWORK_CONNECTION]
|
|
274
|
+
lines.extend(_network_lines(network_events, limit=limit))
|
|
275
|
+
lines.append("")
|
|
276
|
+
|
|
277
|
+
lines.append(_heading("COLLECTORS", color))
|
|
278
|
+
if not session.collectors:
|
|
279
|
+
lines.append(" (no collector status recorded)")
|
|
280
|
+
for collector in session.collectors:
|
|
281
|
+
message = f" {collector.message}" if collector.message else ""
|
|
282
|
+
lines.append(f" {collector.name:<12} {_status_label(collector.state):<9}{message}")
|
|
283
|
+
details = collector.details
|
|
284
|
+
if details:
|
|
285
|
+
compact = ", ".join(f"{key}={value}" for key, value in sorted(details.items()))
|
|
286
|
+
if len(compact) > 150:
|
|
287
|
+
compact = compact[:147] + "..."
|
|
288
|
+
lines.append(f" {compact}")
|
|
289
|
+
lines.append("")
|
|
290
|
+
|
|
291
|
+
if include_environment:
|
|
292
|
+
lines.append(_heading("ENVIRONMENT (initial child diff, sanitized)", color))
|
|
293
|
+
lines.extend(_environment_lines(session))
|
|
294
|
+
lines.append("")
|
|
295
|
+
|
|
296
|
+
lines.append(_heading("SUMMARY", color))
|
|
297
|
+
lines.append(f" {summary.events_total} events observed in this session.")
|
|
298
|
+
lines.append(
|
|
299
|
+
f" {summary.filesystem_created + summary.filesystem_modified + summary.filesystem_deleted} "
|
|
300
|
+
"filesystem changes, "
|
|
301
|
+
f"{summary.processes_started} processes started, "
|
|
302
|
+
f"{summary.network_connections} network connections."
|
|
303
|
+
)
|
|
304
|
+
lines.append("")
|
|
305
|
+
|
|
306
|
+
lines.append(_heading("LIMITATIONS", color))
|
|
307
|
+
lines.append(" - Only changes under the configured watch roots are observed.")
|
|
308
|
+
lines.append(" - Processes and sockets are sampled; short-lived ones can be missed.")
|
|
309
|
+
lines.append(" - File contents are never captured.")
|
|
310
|
+
lines.append(" - CmdScope observability is not sandboxing or isolation.")
|
|
311
|
+
lines.append(" - Runtime environment mutations are not observable externally.")
|
|
312
|
+
for warning in summary.warnings:
|
|
313
|
+
lines.append(f" - {warning}")
|
|
314
|
+
lines.append("")
|
|
315
|
+
lines.append("Generated locally by CmdScope. No data was transmitted.")
|
|
316
|
+
return "\n".join(lines)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def render_sessions(sessions: Iterable[Session], *, color: bool = False) -> str:
|
|
320
|
+
"""Render a compact session-history table."""
|
|
321
|
+
|
|
322
|
+
items = list(sessions)
|
|
323
|
+
if not items:
|
|
324
|
+
return "No sessions recorded yet.\n\nRun one with: cmdscope run -- <command>\n"
|
|
325
|
+
|
|
326
|
+
headers = ("ID", "STARTED (UTC)", "DURATION", "EXIT", "STATUS", "COMMAND")
|
|
327
|
+
rows: list[tuple[str, ...]] = []
|
|
328
|
+
for session in items:
|
|
329
|
+
command = session.command_display or "(unknown)"
|
|
330
|
+
if len(command) > 60:
|
|
331
|
+
command = command[:57] + "..."
|
|
332
|
+
rows.append(
|
|
333
|
+
(
|
|
334
|
+
session.ref,
|
|
335
|
+
_fmt_time(session.started_at or session.created_at).replace(" UTC", ""),
|
|
336
|
+
_fmt_duration(session.duration_s),
|
|
337
|
+
str(session.exit_code) if session.exit_code is not None else "-",
|
|
338
|
+
str(session.status),
|
|
339
|
+
command,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
widths = [
|
|
343
|
+
max(len(headers[index]), *(len(row[index]) for row in rows))
|
|
344
|
+
for index in range(len(headers))
|
|
345
|
+
]
|
|
346
|
+
lines = [
|
|
347
|
+
_heading("CMDSCOPE - SESSIONS", color),
|
|
348
|
+
RULE,
|
|
349
|
+
" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)),
|
|
350
|
+
" ".join("-" * width for width in widths),
|
|
351
|
+
]
|
|
352
|
+
for row in rows:
|
|
353
|
+
lines.append(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _group_items(items: Sequence[ComparisonItem]) -> dict[tuple[str, str], list[str]]:
|
|
358
|
+
grouped: dict[tuple[str, str], list[str]] = {}
|
|
359
|
+
for item in items:
|
|
360
|
+
grouped.setdefault((item.category, item.action), []).append(item.label)
|
|
361
|
+
return grouped
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _comparison_section(
|
|
365
|
+
title: str,
|
|
366
|
+
items: Sequence[ComparisonItem],
|
|
367
|
+
*,
|
|
368
|
+
limit: int | None,
|
|
369
|
+
) -> list[str]:
|
|
370
|
+
lines = [title]
|
|
371
|
+
if not items:
|
|
372
|
+
lines.append(" (none)")
|
|
373
|
+
return lines
|
|
374
|
+
grouped = _group_items(items)
|
|
375
|
+
shown = 0
|
|
376
|
+
for (category, action), labels in sorted(grouped.items()):
|
|
377
|
+
lines.append(f" {category} {action}: {len(labels)}")
|
|
378
|
+
for label in labels:
|
|
379
|
+
if limit is not None and shown >= limit:
|
|
380
|
+
break
|
|
381
|
+
lines.append(f" {label}")
|
|
382
|
+
shown += 1
|
|
383
|
+
if limit is not None and shown >= limit:
|
|
384
|
+
break
|
|
385
|
+
if limit is not None and len(items) > shown:
|
|
386
|
+
lines.append(f" ... {len(items) - shown} more differences (use --all to show)")
|
|
387
|
+
return lines
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def render_comparison(
|
|
391
|
+
result: ComparisonResult,
|
|
392
|
+
*,
|
|
393
|
+
limit: int | None = 50,
|
|
394
|
+
color: bool = False,
|
|
395
|
+
) -> str:
|
|
396
|
+
"""Render differences between two observed sessions."""
|
|
397
|
+
|
|
398
|
+
lines = [
|
|
399
|
+
_heading("CMDSCOPE - SESSION COMPARISON", color),
|
|
400
|
+
RULE,
|
|
401
|
+
f"A: {result.session_a.ref} {result.session_a.command_display}",
|
|
402
|
+
f" {result.events_a} events observed",
|
|
403
|
+
f"B: {result.session_b.ref} {result.session_b.command_display}",
|
|
404
|
+
f" {result.events_b} events observed",
|
|
405
|
+
"",
|
|
406
|
+
_heading("SUMMARY", color),
|
|
407
|
+
f" Shared observations: {result.common_count}",
|
|
408
|
+
f" Only in A: {len(result.only_in_a)}",
|
|
409
|
+
f" Only in B: {len(result.only_in_b)}",
|
|
410
|
+
"",
|
|
411
|
+
]
|
|
412
|
+
lines.extend(_comparison_section("ONLY IN A", result.only_in_a, limit=limit))
|
|
413
|
+
lines.append("")
|
|
414
|
+
lines.extend(_comparison_section("ONLY IN B", result.only_in_b, limit=limit))
|
|
415
|
+
lines.append("")
|
|
416
|
+
if not result.only_in_a and not result.only_in_b:
|
|
417
|
+
lines.append("No differences in observed events.")
|
|
418
|
+
lines.append("")
|
|
419
|
+
lines.append("Comparison uses normalized paths and event signatures; timestamps are ignored.")
|
|
420
|
+
return "\n".join(lines)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def category_counts(events: Sequence[StoredEvent]) -> Counter[str]:
|
|
424
|
+
"""Small helper used by reports/tests to summarize categories."""
|
|
425
|
+
|
|
426
|
+
counts: Counter[str] = Counter()
|
|
427
|
+
for event in events:
|
|
428
|
+
counts[str(event.kind).split(".", 1)[0]] += 1
|
|
429
|
+
return counts
|