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/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