debugbrief 1.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.
- debugbrief/__init__.py +13 -0
- debugbrief/__main__.py +10 -0
- debugbrief/cli.py +843 -0
- debugbrief/command_runner.py +233 -0
- debugbrief/derive.py +353 -0
- debugbrief/doctor.py +324 -0
- debugbrief/filters.py +280 -0
- debugbrief/git_utils.py +268 -0
- debugbrief/models.py +320 -0
- debugbrief/paths.py +130 -0
- debugbrief/redaction.py +103 -0
- debugbrief/reporters/__init__.py +108 -0
- debugbrief/reporters/base.py +396 -0
- debugbrief/reporters/handoff.py +96 -0
- debugbrief/reporters/incident.py +99 -0
- debugbrief/reporters/pr.py +28 -0
- debugbrief/reports_index.py +50 -0
- debugbrief/session_manager.py +361 -0
- debugbrief/sessions_index.py +78 -0
- debugbrief/utils.py +151 -0
- debugbrief-1.1.0.dist-info/METADATA +143 -0
- debugbrief-1.1.0.dist-info/RECORD +26 -0
- debugbrief-1.1.0.dist-info/WHEEL +5 -0
- debugbrief-1.1.0.dist-info/entry_points.txt +2 -0
- debugbrief-1.1.0.dist-info/licenses/LICENSE +21 -0
- debugbrief-1.1.0.dist-info/top_level.txt +1 -0
debugbrief/doctor.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Health-check logic for ``debugbrief doctor``.
|
|
2
|
+
|
|
3
|
+
Runs a series of read-only checks (with an optional, safe ``--fix``) and reports
|
|
4
|
+
PASS / WARN / FAIL lines plus an overall verdict and exit code:
|
|
5
|
+
|
|
6
|
+
0 ready (all PASS)
|
|
7
|
+
1 usable with warnings (>=1 WARN, no FAIL)
|
|
8
|
+
2 blocking issues (>=1 FAIL)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from . import git_utils
|
|
21
|
+
from .paths import DEBUGBRIEF_DIRNAME, ProjectPaths, ensure_local_ignore
|
|
22
|
+
from .utils import is_supported_platform, read_json
|
|
23
|
+
|
|
24
|
+
PASS = "PASS"
|
|
25
|
+
WARN = "WARN"
|
|
26
|
+
FAIL = "FAIL"
|
|
27
|
+
|
|
28
|
+
EXIT_READY = 0
|
|
29
|
+
EXIT_WARN = 1
|
|
30
|
+
EXIT_BLOCKED = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CheckResult:
|
|
35
|
+
level: str
|
|
36
|
+
name: str
|
|
37
|
+
detail: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class DoctorReport:
|
|
42
|
+
checks: List[CheckResult]
|
|
43
|
+
exit_code: int
|
|
44
|
+
summary: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _overall(checks: List[CheckResult]) -> Tuple[int, str]:
|
|
48
|
+
if any(c.level == FAIL for c in checks):
|
|
49
|
+
return EXIT_BLOCKED, "DebugBrief has blocking issues."
|
|
50
|
+
if any(c.level == WARN for c in checks):
|
|
51
|
+
return EXIT_WARN, "DebugBrief is usable with warnings."
|
|
52
|
+
return EXIT_READY, "DebugBrief is ready."
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _exclude_has_entry(paths: ProjectPaths) -> Optional[bool]:
|
|
56
|
+
"""Return True/False if the exclude entry is present, or None if N/A."""
|
|
57
|
+
if not paths.is_git_repo or paths.repo_root is None:
|
|
58
|
+
return None
|
|
59
|
+
exclude = paths.repo_root / ".git" / "info" / "exclude"
|
|
60
|
+
if not exclude.exists():
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
lines = {
|
|
64
|
+
line.strip() for line in exclude.read_text(encoding="utf-8").splitlines()
|
|
65
|
+
}
|
|
66
|
+
except OSError:
|
|
67
|
+
return False
|
|
68
|
+
return f"{DEBUGBRIEF_DIRNAME}/" in lines or DEBUGBRIEF_DIRNAME in lines
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_doctor(paths: ProjectPaths, fix: bool = False) -> DoctorReport:
|
|
72
|
+
checks: List[CheckResult] = []
|
|
73
|
+
|
|
74
|
+
# Optional safe fixes applied up-front so subsequent checks reflect them.
|
|
75
|
+
fix_notes: List[str] = []
|
|
76
|
+
if fix:
|
|
77
|
+
try:
|
|
78
|
+
paths.ensure_directories()
|
|
79
|
+
fix_notes.append("ensured .debugbrief/ directories exist")
|
|
80
|
+
except OSError as exc:
|
|
81
|
+
fix_notes.append(f"could not create .debugbrief/ ({exc})")
|
|
82
|
+
changed, _warnings = ensure_local_ignore(paths)
|
|
83
|
+
if changed:
|
|
84
|
+
fix_notes.append("added .debugbrief/ to .git/info/exclude")
|
|
85
|
+
|
|
86
|
+
# 1. Platform
|
|
87
|
+
if is_supported_platform():
|
|
88
|
+
checks.append(CheckResult(PASS, "Platform", f"{platform.system()} (supported)"))
|
|
89
|
+
else:
|
|
90
|
+
checks.append(
|
|
91
|
+
CheckResult(
|
|
92
|
+
FAIL,
|
|
93
|
+
"Platform",
|
|
94
|
+
f"{platform.system()} is not supported (Unix-like only).",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# 2. Python version
|
|
99
|
+
py = ".".join(str(v) for v in sys.version_info[:3])
|
|
100
|
+
if sys.version_info[:2] >= (3, 9):
|
|
101
|
+
checks.append(CheckResult(PASS, "Python version", f"{py} (>= 3.9)"))
|
|
102
|
+
else:
|
|
103
|
+
checks.append(
|
|
104
|
+
CheckResult(FAIL, "Python version", f"{py} (3.9+ required)")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# 3. Project root
|
|
108
|
+
checks.append(
|
|
109
|
+
CheckResult(PASS, "Project root", str(paths.project_root))
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# 4. Inside a Git repo?
|
|
113
|
+
if paths.is_git_repo:
|
|
114
|
+
checks.append(CheckResult(PASS, "Git repository", "inside a Git repo"))
|
|
115
|
+
# 5. Branch / detached HEAD
|
|
116
|
+
if git_utils.is_detached_head(paths.project_root):
|
|
117
|
+
checks.append(
|
|
118
|
+
CheckResult(
|
|
119
|
+
WARN,
|
|
120
|
+
"Git branch",
|
|
121
|
+
"HEAD is detached; consider working on a branch.",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
branch = git_utils.current_branch(paths.project_root) or "(unborn branch)"
|
|
126
|
+
checks.append(CheckResult(PASS, "Git branch", branch))
|
|
127
|
+
else:
|
|
128
|
+
checks.append(
|
|
129
|
+
CheckResult(
|
|
130
|
+
WARN,
|
|
131
|
+
"Git repository",
|
|
132
|
+
"not inside a Git repo; Git metadata capture is disabled "
|
|
133
|
+
"(this is supported).",
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# 6. .debugbrief directory exists?
|
|
138
|
+
if paths.base_dir.is_dir():
|
|
139
|
+
checks.append(
|
|
140
|
+
CheckResult(PASS, ".debugbrief directory", str(paths.base_dir))
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
checks.append(
|
|
144
|
+
CheckResult(
|
|
145
|
+
WARN,
|
|
146
|
+
".debugbrief directory",
|
|
147
|
+
"does not exist yet (created on first 'start', or run "
|
|
148
|
+
"'debugbrief doctor --fix').",
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# 7. Writable / creatable?
|
|
153
|
+
writable_target = paths.base_dir if paths.base_dir.exists() else paths.project_root
|
|
154
|
+
if os.access(writable_target, os.W_OK):
|
|
155
|
+
checks.append(
|
|
156
|
+
CheckResult(PASS, "Storage writable", f"{writable_target} is writable")
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
checks.append(
|
|
160
|
+
CheckResult(
|
|
161
|
+
FAIL,
|
|
162
|
+
"Storage writable",
|
|
163
|
+
f"{writable_target} is not writable; cannot persist sessions.",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 8. .git/info/exclude contains .debugbrief/
|
|
168
|
+
has_exclude = _exclude_has_entry(paths)
|
|
169
|
+
if has_exclude is None:
|
|
170
|
+
checks.append(
|
|
171
|
+
CheckResult(PASS, "Local ignore", "N/A (not a Git repo)")
|
|
172
|
+
)
|
|
173
|
+
elif has_exclude:
|
|
174
|
+
checks.append(
|
|
175
|
+
CheckResult(PASS, "Local ignore", ".debugbrief/ is in .git/info/exclude")
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
checks.append(
|
|
179
|
+
CheckResult(
|
|
180
|
+
WARN,
|
|
181
|
+
"Local ignore",
|
|
182
|
+
".debugbrief/ is not in .git/info/exclude (run "
|
|
183
|
+
"'debugbrief doctor --fix' to add it).",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# 9-12. Active session checks
|
|
188
|
+
_active_session_checks(paths, checks)
|
|
189
|
+
|
|
190
|
+
# 13. Reports directory writable
|
|
191
|
+
reports_dir = paths.reports_dir
|
|
192
|
+
if reports_dir.is_dir():
|
|
193
|
+
if os.access(reports_dir, os.W_OK):
|
|
194
|
+
checks.append(
|
|
195
|
+
CheckResult(PASS, "Reports directory", f"{reports_dir} (writable)")
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
checks.append(
|
|
199
|
+
CheckResult(
|
|
200
|
+
FAIL, "Reports directory", f"{reports_dir} is not writable."
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
checks.append(
|
|
205
|
+
CheckResult(
|
|
206
|
+
WARN,
|
|
207
|
+
"Reports directory",
|
|
208
|
+
"does not exist yet (created on 'end', or run "
|
|
209
|
+
"'debugbrief doctor --fix').",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# 14. Experimental shell mode
|
|
214
|
+
checks.append(
|
|
215
|
+
CheckResult(
|
|
216
|
+
PASS,
|
|
217
|
+
"Experimental shell mode",
|
|
218
|
+
"'start --shell' is unavailable by design in v1.",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
exit_code, summary = _overall(checks)
|
|
223
|
+
if fix and fix_notes:
|
|
224
|
+
# Surface what --fix did as an informational PASS line.
|
|
225
|
+
checks.insert(
|
|
226
|
+
0, CheckResult(PASS, "Applied --fix", "; ".join(fix_notes))
|
|
227
|
+
)
|
|
228
|
+
return DoctorReport(checks=checks, exit_code=exit_code, summary=summary)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _active_session_checks(
|
|
232
|
+
paths: ProjectPaths, checks: List[CheckResult]
|
|
233
|
+
) -> None:
|
|
234
|
+
pointer_path = paths.active_session_file
|
|
235
|
+
if not pointer_path.exists():
|
|
236
|
+
checks.append(
|
|
237
|
+
CheckResult(PASS, "Active session", "none (no active_session.json)")
|
|
238
|
+
)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# 9. exists
|
|
242
|
+
checks.append(CheckResult(PASS, "Active session", "active_session.json exists"))
|
|
243
|
+
|
|
244
|
+
# 10. valid JSON
|
|
245
|
+
try:
|
|
246
|
+
pointer = read_json(pointer_path)
|
|
247
|
+
except (ValueError, OSError) as exc:
|
|
248
|
+
checks.append(
|
|
249
|
+
CheckResult(
|
|
250
|
+
FAIL,
|
|
251
|
+
"Active session JSON",
|
|
252
|
+
f"active_session.json is not valid JSON ({exc}). Remove it to recover.",
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
if not isinstance(pointer, dict) or "session_id" not in pointer:
|
|
257
|
+
checks.append(
|
|
258
|
+
CheckResult(
|
|
259
|
+
FAIL,
|
|
260
|
+
"Active session JSON",
|
|
261
|
+
"active_session.json is malformed (missing session_id).",
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
checks.append(CheckResult(PASS, "Active session JSON", "valid"))
|
|
266
|
+
|
|
267
|
+
session_id = pointer.get("session_id", "")
|
|
268
|
+
session_file = paths.session_file(session_id)
|
|
269
|
+
|
|
270
|
+
# 12. interrupted?
|
|
271
|
+
if not session_file.exists():
|
|
272
|
+
checks.append(
|
|
273
|
+
CheckResult(
|
|
274
|
+
WARN,
|
|
275
|
+
"Session integrity",
|
|
276
|
+
"session looks interrupted (session file missing). Run "
|
|
277
|
+
"'debugbrief status' for recovery steps.",
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
session_data = read_json(session_file)
|
|
284
|
+
except (ValueError, OSError) as exc:
|
|
285
|
+
checks.append(
|
|
286
|
+
CheckResult(
|
|
287
|
+
FAIL,
|
|
288
|
+
"Session integrity",
|
|
289
|
+
f"session file is unreadable ({exc}).",
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
status = session_data.get("status")
|
|
295
|
+
if status != "ACTIVE":
|
|
296
|
+
checks.append(
|
|
297
|
+
CheckResult(
|
|
298
|
+
WARN,
|
|
299
|
+
"Session integrity",
|
|
300
|
+
f"active pointer references a session with status {status!r}.",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
checks.append(CheckResult(PASS, "Session integrity", "consistent"))
|
|
305
|
+
|
|
306
|
+
# 11. points to current project root?
|
|
307
|
+
session_root = session_data.get("project_root", "")
|
|
308
|
+
try:
|
|
309
|
+
same = Path(session_root).resolve() == Path(paths.project_root).resolve()
|
|
310
|
+
except (OSError, ValueError):
|
|
311
|
+
same = session_root == str(paths.project_root)
|
|
312
|
+
if same:
|
|
313
|
+
checks.append(
|
|
314
|
+
CheckResult(PASS, "Session project root", "matches current project")
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
checks.append(
|
|
318
|
+
CheckResult(
|
|
319
|
+
WARN,
|
|
320
|
+
"Session project root",
|
|
321
|
+
f"active session root ({session_root}) differs from current "
|
|
322
|
+
f"project root ({paths.project_root}).",
|
|
323
|
+
)
|
|
324
|
+
)
|
debugbrief/filters.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Command classification, report-noise filtering, and deduplication.
|
|
2
|
+
|
|
3
|
+
This module is deterministic and dependency-free. It never guesses intent: it
|
|
4
|
+
only recognizes well-known tool invocations by their token patterns and reports
|
|
5
|
+
pass/fail strictly from real exit codes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shlex
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .models import (
|
|
15
|
+
COMMAND_STATUS_ERROR,
|
|
16
|
+
COMMAND_STATUS_FAILED,
|
|
17
|
+
COMMAND_STATUS_PASSED,
|
|
18
|
+
COMMAND_STATUS_TIMED_OUT,
|
|
19
|
+
CommandClassification,
|
|
20
|
+
CommandData,
|
|
21
|
+
Event,
|
|
22
|
+
)
|
|
23
|
+
from .utils import parse_iso8601
|
|
24
|
+
|
|
25
|
+
# Default window (seconds) within which identical commands are squashed in reports.
|
|
26
|
+
DEFAULT_DEDUP_WINDOW_SECONDS = 30
|
|
27
|
+
|
|
28
|
+
# Low-value commands dropped from reports unless they failed.
|
|
29
|
+
_NOISE_SINGLE = {"ls", "ll", "pwd", "cd", "clear", "history", "cat"}
|
|
30
|
+
_NOISE_PAIRS = {("git", "status")}
|
|
31
|
+
|
|
32
|
+
# (pattern_tokens, tool) for test-command detection.
|
|
33
|
+
_TEST_PATTERNS: List[Tuple[List[str], str]] = [
|
|
34
|
+
(["pytest"], "pytest"),
|
|
35
|
+
(["py.test"], "pytest"),
|
|
36
|
+
(["npm", "test"], "npm"),
|
|
37
|
+
(["npm", "run", "test"], "npm"),
|
|
38
|
+
(["pnpm", "test"], "pnpm"),
|
|
39
|
+
(["pnpm", "run", "test"], "pnpm"),
|
|
40
|
+
(["yarn", "test"], "yarn"),
|
|
41
|
+
(["yarn", "run", "test"], "yarn"),
|
|
42
|
+
(["jest"], "jest"),
|
|
43
|
+
(["go", "test"], "go"),
|
|
44
|
+
(["cargo", "test"], "cargo"),
|
|
45
|
+
(["rspec"], "rspec"),
|
|
46
|
+
(["bundle", "exec", "rspec"], "rspec"),
|
|
47
|
+
(["mvn", "test"], "maven"),
|
|
48
|
+
(["gradle", "test"], "gradle"),
|
|
49
|
+
(["./gradlew", "test"], "gradle"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# (pattern_tokens, tool, category) for build/lint/typecheck detection.
|
|
53
|
+
_BUILD_PATTERNS: List[Tuple[List[str], str, str]] = [
|
|
54
|
+
(["npm", "run", "build"], "npm", "build"),
|
|
55
|
+
(["pnpm", "build"], "pnpm", "build"),
|
|
56
|
+
(["pnpm", "run", "build"], "pnpm", "build"),
|
|
57
|
+
(["yarn", "build"], "yarn", "build"),
|
|
58
|
+
(["npm", "run", "lint"], "npm", "lint"),
|
|
59
|
+
(["pnpm", "lint"], "pnpm", "lint"),
|
|
60
|
+
(["pnpm", "run", "lint"], "pnpm", "lint"),
|
|
61
|
+
(["yarn", "lint"], "yarn", "lint"),
|
|
62
|
+
(["npm", "run", "typecheck"], "npm", "typecheck"),
|
|
63
|
+
(["pnpm", "typecheck"], "pnpm", "typecheck"),
|
|
64
|
+
(["pnpm", "run", "typecheck"], "pnpm", "typecheck"),
|
|
65
|
+
(["yarn", "typecheck"], "yarn", "typecheck"),
|
|
66
|
+
(["mypy"], "mypy", "typecheck"),
|
|
67
|
+
(["ruff", "check"], "ruff", "lint"),
|
|
68
|
+
(["black", "--check"], "black", "lint"),
|
|
69
|
+
(["tsc", "--noEmit"], "tsc", "typecheck"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _tokenize(command: str) -> List[str]:
|
|
74
|
+
"""Tokenize a command string, tolerating shlex parse errors."""
|
|
75
|
+
try:
|
|
76
|
+
return shlex.split(command)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return command.split()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _contains_subsequence(tokens: List[str], pattern: List[str]) -> bool:
|
|
82
|
+
"""Return True if ``pattern`` appears as a contiguous run inside ``tokens``."""
|
|
83
|
+
if not pattern or len(pattern) > len(tokens):
|
|
84
|
+
return False
|
|
85
|
+
for start in range(len(tokens) - len(pattern) + 1):
|
|
86
|
+
if tokens[start : start + len(pattern)] == pattern:
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _match_test(tokens: List[str]) -> Optional[str]:
|
|
92
|
+
for pattern, tool in _TEST_PATTERNS:
|
|
93
|
+
if _contains_subsequence(tokens, pattern):
|
|
94
|
+
return tool
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _match_build(tokens: List[str]) -> Optional[Tuple[str, str]]:
|
|
99
|
+
for pattern, tool, category in _BUILD_PATTERNS:
|
|
100
|
+
if _contains_subsequence(tokens, pattern):
|
|
101
|
+
return tool, category
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def status_from_outcome(
|
|
106
|
+
exit_code: Optional[int], timed_out: bool, errored: bool
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Map an execution outcome to a command status string."""
|
|
109
|
+
if timed_out:
|
|
110
|
+
return COMMAND_STATUS_TIMED_OUT
|
|
111
|
+
if errored:
|
|
112
|
+
return COMMAND_STATUS_ERROR
|
|
113
|
+
if exit_code == 0:
|
|
114
|
+
return COMMAND_STATUS_PASSED
|
|
115
|
+
return COMMAND_STATUS_FAILED
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def classify_command(
|
|
119
|
+
command: str,
|
|
120
|
+
exit_code: Optional[int],
|
|
121
|
+
timed_out: bool = False,
|
|
122
|
+
errored: bool = False,
|
|
123
|
+
) -> CommandClassification:
|
|
124
|
+
"""Classify a command into test / verification categories.
|
|
125
|
+
|
|
126
|
+
A command is verification-worthy only if it is a recognized test command
|
|
127
|
+
that exited 0, or a recognized build/lint/typecheck command that exited 0.
|
|
128
|
+
Pass/fail is derived strictly from the real exit code.
|
|
129
|
+
"""
|
|
130
|
+
tokens = _tokenize(command)
|
|
131
|
+
status = status_from_outcome(exit_code, timed_out, errored)
|
|
132
|
+
passed = status == COMMAND_STATUS_PASSED
|
|
133
|
+
|
|
134
|
+
test_tool = _match_test(tokens)
|
|
135
|
+
if test_tool is not None:
|
|
136
|
+
return CommandClassification(
|
|
137
|
+
is_test=True,
|
|
138
|
+
is_verification=passed,
|
|
139
|
+
tool=test_tool,
|
|
140
|
+
status=status,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
build_match = _match_build(tokens)
|
|
144
|
+
if build_match is not None:
|
|
145
|
+
tool, _category = build_match
|
|
146
|
+
return CommandClassification(
|
|
147
|
+
is_test=False,
|
|
148
|
+
is_verification=passed,
|
|
149
|
+
tool=tool,
|
|
150
|
+
status=status,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return CommandClassification(
|
|
154
|
+
is_test=False,
|
|
155
|
+
is_verification=False,
|
|
156
|
+
tool=None,
|
|
157
|
+
status=status,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_noise_command(command: str) -> bool:
|
|
162
|
+
"""Return True for low-value commands that reports should usually omit."""
|
|
163
|
+
stripped = command.strip()
|
|
164
|
+
if not stripped:
|
|
165
|
+
return True
|
|
166
|
+
tokens = stripped.split()
|
|
167
|
+
if not tokens:
|
|
168
|
+
return True
|
|
169
|
+
if tokens[0] in _NOISE_SINGLE:
|
|
170
|
+
return True
|
|
171
|
+
return len(tokens) >= 2 and (tokens[0], tokens[1]) in _NOISE_PAIRS
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class ReportCommand:
|
|
176
|
+
"""A command as it should appear in a report, after dedup/filtering."""
|
|
177
|
+
|
|
178
|
+
command: str
|
|
179
|
+
count: int
|
|
180
|
+
first_timestamp: str
|
|
181
|
+
last_timestamp: str
|
|
182
|
+
exit_code: Optional[int]
|
|
183
|
+
status: str
|
|
184
|
+
is_test: bool
|
|
185
|
+
is_verification: bool
|
|
186
|
+
tool: Optional[str]
|
|
187
|
+
stdout_truncated: bool = False
|
|
188
|
+
stderr_truncated: bool = False
|
|
189
|
+
stderr_preview: str = ""
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def failed(self) -> bool:
|
|
193
|
+
return self.status in (
|
|
194
|
+
COMMAND_STATUS_FAILED,
|
|
195
|
+
COMMAND_STATUS_TIMED_OUT,
|
|
196
|
+
COMMAND_STATUS_ERROR,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _event_seconds(event: Event) -> float:
|
|
201
|
+
try:
|
|
202
|
+
return parse_iso8601(event.timestamp).timestamp()
|
|
203
|
+
except (ValueError, TypeError):
|
|
204
|
+
return 0.0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def build_report_commands(
|
|
208
|
+
command_events: List[Event],
|
|
209
|
+
dedup_window_seconds: int = DEFAULT_DEDUP_WINDOW_SECONDS,
|
|
210
|
+
drop_noise: bool = True,
|
|
211
|
+
) -> List[ReportCommand]:
|
|
212
|
+
"""Turn raw command events into a concise, deduplicated report list.
|
|
213
|
+
|
|
214
|
+
- Noise commands (ls, cd, git status, ...) are dropped unless they failed.
|
|
215
|
+
- Identical commands within ``dedup_window_seconds`` are squashed, tracking
|
|
216
|
+
a count and keeping the most recent timestamp and outcome.
|
|
217
|
+
"""
|
|
218
|
+
ordered = sorted(command_events, key=_event_seconds)
|
|
219
|
+
results: List[ReportCommand] = []
|
|
220
|
+
|
|
221
|
+
for event in ordered:
|
|
222
|
+
data = CommandData.from_dict(event.data)
|
|
223
|
+
command_text = data.command
|
|
224
|
+
cls = data.classification
|
|
225
|
+
status = cls.status
|
|
226
|
+
is_failure = status in (
|
|
227
|
+
COMMAND_STATUS_FAILED,
|
|
228
|
+
COMMAND_STATUS_TIMED_OUT,
|
|
229
|
+
COMMAND_STATUS_ERROR,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if drop_noise and is_noise_command(command_text) and not is_failure:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
ts = event.timestamp
|
|
236
|
+
ts_seconds = _event_seconds(event)
|
|
237
|
+
|
|
238
|
+
merged = False
|
|
239
|
+
for existing in results:
|
|
240
|
+
if existing.command != command_text:
|
|
241
|
+
continue
|
|
242
|
+
try:
|
|
243
|
+
gap = ts_seconds - parse_iso8601(existing.last_timestamp).timestamp()
|
|
244
|
+
except (ValueError, TypeError):
|
|
245
|
+
gap = dedup_window_seconds + 1
|
|
246
|
+
if 0 <= gap <= dedup_window_seconds:
|
|
247
|
+
existing.count += 1
|
|
248
|
+
existing.last_timestamp = ts
|
|
249
|
+
existing.exit_code = data.exit_code
|
|
250
|
+
existing.status = status
|
|
251
|
+
existing.is_test = cls.is_test
|
|
252
|
+
existing.is_verification = cls.is_verification
|
|
253
|
+
existing.tool = cls.tool
|
|
254
|
+
existing.stdout_truncated = data.stdout_truncated
|
|
255
|
+
existing.stderr_truncated = data.stderr_truncated
|
|
256
|
+
existing.stderr_preview = data.stderr_preview
|
|
257
|
+
merged = True
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
if merged:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
results.append(
|
|
264
|
+
ReportCommand(
|
|
265
|
+
command=command_text,
|
|
266
|
+
count=1,
|
|
267
|
+
first_timestamp=ts,
|
|
268
|
+
last_timestamp=ts,
|
|
269
|
+
exit_code=data.exit_code,
|
|
270
|
+
status=status,
|
|
271
|
+
is_test=cls.is_test,
|
|
272
|
+
is_verification=cls.is_verification,
|
|
273
|
+
tool=cls.tool,
|
|
274
|
+
stdout_truncated=data.stdout_truncated,
|
|
275
|
+
stderr_truncated=data.stderr_truncated,
|
|
276
|
+
stderr_preview=data.stderr_preview,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return results
|