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.
@@ -0,0 +1,233 @@
1
+ """Execute commands via subprocess and capture honest, bounded results.
2
+
3
+ The runner never fakes an exit code and never claims success it did not observe.
4
+ While the command runs, its stdout and stderr stream live to the user's own
5
+ terminal, line by line and unmodified, so a test run or build behaves exactly as
6
+ it would outside DebugBrief. The full output is accumulated in parallel and
7
+ stored as bounded previews (not full logs), explicitly flagged when truncated.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import IO, List, Optional
21
+
22
+ from . import filters
23
+ from .models import CommandData
24
+ from .redaction import redact_text
25
+ from .utils import (
26
+ DEFAULT_STDERR_PREVIEW_LIMIT,
27
+ DEFAULT_STDOUT_PREVIEW_LIMIT,
28
+ now_iso8601,
29
+ truncate_text,
30
+ )
31
+
32
+ DEFAULT_TIMEOUT_SECONDS = 300
33
+
34
+
35
+ @dataclass
36
+ class RunResult:
37
+ """The outcome of running one command, ready to persist and report."""
38
+
39
+ command_data: CommandData
40
+ timed_out: bool
41
+ errored: bool
42
+ error_message: Optional[str] = None
43
+
44
+ @property
45
+ def propagated_exit_code(self) -> int:
46
+ """Exit code DebugBrief should return to its own caller.
47
+
48
+ Real exit codes pass through; timeouts/errors map to a nonzero code so
49
+ callers and scripts see failure.
50
+ """
51
+ code = self.command_data.exit_code
52
+ if code is None:
53
+ return 1
54
+ return code
55
+
56
+
57
+ def _pump_stream(
58
+ stream: IO[str], echo_to: Optional[IO[str]], chunks: List[str]
59
+ ) -> None:
60
+ """Drain ``stream`` line by line, echoing live and accumulating.
61
+
62
+ Runs on a daemon reader thread, one per pipe. Lines are passed through to
63
+ ``echo_to`` unmodified (it is the user's own terminal) and appended to
64
+ ``chunks`` for the stored preview. A broken or closed echo target stops the
65
+ echo but never the capture.
66
+ """
67
+ try:
68
+ for line in iter(stream.readline, ""):
69
+ chunks.append(line)
70
+ if echo_to is not None:
71
+ try:
72
+ echo_to.write(line)
73
+ echo_to.flush()
74
+ except (OSError, ValueError):
75
+ echo_to = None
76
+ finally:
77
+ with contextlib.suppress(OSError):
78
+ stream.close()
79
+
80
+
81
+ def run_command(
82
+ command: str,
83
+ cwd: Path,
84
+ use_shell: bool = False,
85
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
86
+ stdout_limit: int = DEFAULT_STDOUT_PREVIEW_LIMIT,
87
+ stderr_limit: int = DEFAULT_STDERR_PREVIEW_LIMIT,
88
+ redact: bool = True,
89
+ echo: bool = True,
90
+ ) -> RunResult:
91
+ """Run ``command`` from ``cwd`` and capture a :class:`CommandData`.
92
+
93
+ When ``use_shell`` is False (default), the command is parsed with
94
+ ``shlex.split`` and executed without a shell. When ``use_shell`` is True,
95
+ the command runs through the system shell (shell features allowed).
96
+
97
+ While the command runs its stdout and stderr are echoed live to the
98
+ corresponding ``sys`` streams (disable with ``echo=False``). The echo is the
99
+ raw output; only the stored previews are redacted.
100
+
101
+ By default captured output and the command string are passed through
102
+ best-effort secret redaction before they are returned, so raw secrets never
103
+ reach the session file. Pass ``redact=False`` to store the raw text.
104
+ """
105
+ started_at = now_iso8601()
106
+ start_monotonic = time.monotonic()
107
+
108
+ timed_out = False
109
+ errored = False
110
+ error_message: Optional[str] = None
111
+ exit_code: Optional[int] = None
112
+ stdout_text = ""
113
+ stderr_text = ""
114
+
115
+ popen_args: object
116
+ if use_shell:
117
+ popen_args = command
118
+ else:
119
+ try:
120
+ parsed: List[str] = shlex.split(command)
121
+ except ValueError as exc:
122
+ parsed = []
123
+ errored = True
124
+ error_message = f"Could not parse command: {exc}"
125
+ if not parsed and not errored:
126
+ errored = True
127
+ error_message = "Empty command."
128
+ popen_args = parsed
129
+
130
+ process: Optional["subprocess.Popen[str]"] = None
131
+ if not errored:
132
+ try:
133
+ process = subprocess.Popen(
134
+ popen_args,
135
+ cwd=str(cwd),
136
+ shell=use_shell,
137
+ stdout=subprocess.PIPE,
138
+ stderr=subprocess.PIPE,
139
+ text=True,
140
+ errors="replace",
141
+ bufsize=1,
142
+ )
143
+ except FileNotFoundError as exc:
144
+ errored = True
145
+ exit_code = None
146
+ error_message = f"Command not found: {exc.filename or command}"
147
+ except PermissionError as exc:
148
+ errored = True
149
+ exit_code = None
150
+ error_message = f"Permission denied: {exc.filename or command}"
151
+ except OSError as exc: # pragma: no cover - defensive
152
+ errored = True
153
+ exit_code = None
154
+ error_message = f"Failed to execute command: {exc}"
155
+
156
+ if process is not None:
157
+ stdout_chunks: List[str] = []
158
+ stderr_chunks: List[str] = []
159
+ readers = [
160
+ threading.Thread(
161
+ target=_pump_stream,
162
+ args=(process.stdout, sys.stdout if echo else None, stdout_chunks),
163
+ daemon=True,
164
+ ),
165
+ threading.Thread(
166
+ target=_pump_stream,
167
+ args=(process.stderr, sys.stderr if echo else None, stderr_chunks),
168
+ daemon=True,
169
+ ),
170
+ ]
171
+ for reader in readers:
172
+ reader.start()
173
+ try:
174
+ exit_code = process.wait(timeout=timeout_seconds)
175
+ # Normal exit: drain whatever is left in the pipes before moving on.
176
+ for reader in readers:
177
+ reader.join()
178
+ except subprocess.TimeoutExpired:
179
+ timed_out = True
180
+ exit_code = None
181
+ process.kill()
182
+ process.wait()
183
+ # Join briefly and keep whatever partial output was accumulated.
184
+ for reader in readers:
185
+ reader.join(timeout=2.0)
186
+ error_message = f"Command timed out after {timeout_seconds}s."
187
+ stdout_text = "".join(stdout_chunks)
188
+ stderr_text = "".join(stderr_chunks)
189
+
190
+ ended_at = now_iso8601()
191
+ duration = round(time.monotonic() - start_monotonic, 3)
192
+
193
+ stdout_preview, stdout_truncated = truncate_text(stdout_text, stdout_limit)
194
+ stderr_preview, stderr_truncated = truncate_text(stderr_text, stderr_limit)
195
+
196
+ # Classification is derived from the command tokens and the real exit code,
197
+ # so it is computed before redaction may alter the stored command string.
198
+ classification = filters.classify_command(
199
+ command=command,
200
+ exit_code=exit_code,
201
+ timed_out=timed_out,
202
+ errored=errored,
203
+ )
204
+
205
+ stored_command = command
206
+ redacted = False
207
+ if redact:
208
+ stored_command, n_cmd = redact_text(command)
209
+ stdout_preview, n_out = redact_text(stdout_preview)
210
+ stderr_preview, n_err = redact_text(stderr_preview)
211
+ redacted = (n_cmd + n_out + n_err) > 0
212
+
213
+ command_data = CommandData(
214
+ command=stored_command,
215
+ started_at=started_at,
216
+ ended_at=ended_at,
217
+ duration_seconds=duration,
218
+ exit_code=exit_code,
219
+ stdout_preview=stdout_preview,
220
+ stderr_preview=stderr_preview,
221
+ stdout_truncated=stdout_truncated,
222
+ stderr_truncated=stderr_truncated,
223
+ used_shell=use_shell,
224
+ classification=classification,
225
+ redacted=redacted,
226
+ )
227
+
228
+ return RunResult(
229
+ command_data=command_data,
230
+ timed_out=timed_out,
231
+ errored=errored,
232
+ error_message=error_message,
233
+ )
debugbrief/derive.py ADDED
@@ -0,0 +1,353 @@
1
+ """Deterministic derivations shared by every report mode.
2
+
3
+ Everything here is computed only from recorded events. Nothing asserts a cause,
4
+ and no value is invented: anything the data cannot support is left as ``None``
5
+ or an empty list so the reporters can omit the corresponding section.
6
+
7
+ The reporters used to restate counts and echo notes. These derivations instead
8
+ reconstruct the shape of the investigation (what failed, what then passed, what
9
+ changed in between, what was ruled out) strictly from evidence.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import List, Optional
17
+
18
+ from .models import (
19
+ COMMAND_STATUS_ERROR,
20
+ COMMAND_STATUS_FAILED,
21
+ COMMAND_STATUS_PASSED,
22
+ COMMAND_STATUS_TIMED_OUT,
23
+ CommandData,
24
+ Session,
25
+ )
26
+ from .utils import human_duration, parse_iso8601
27
+
28
+ _FAIL_STATUSES = (
29
+ COMMAND_STATUS_FAILED,
30
+ COMMAND_STATUS_TIMED_OUT,
31
+ COMMAND_STATUS_ERROR,
32
+ )
33
+
34
+ # Cap how much of an error line we quote verbatim.
35
+ _OBSERVED_ERROR_LIMIT = 300
36
+
37
+ # Phrases that mark a recorded note as forward-looking (used for handoff steps).
38
+ # Matched on word boundaries so "retry" does not look like "try".
39
+ _NEXT_STEP_HINTS = (
40
+ "next",
41
+ "todo",
42
+ "to do",
43
+ "try",
44
+ "should",
45
+ "need to",
46
+ "needs to",
47
+ "follow up",
48
+ "follow-up",
49
+ "investigate",
50
+ "check",
51
+ )
52
+ _NEXT_STEP_RE = re.compile(
53
+ r"\b(?:" + "|".join(re.escape(h) for h in _NEXT_STEP_HINTS) + r")\b",
54
+ re.IGNORECASE,
55
+ )
56
+
57
+
58
+ @dataclass
59
+ class CommandRecord:
60
+ """A single command event, flattened for derivation and reporting."""
61
+
62
+ command: str
63
+ timestamp: str
64
+ status: str
65
+ exit_code: Optional[int]
66
+ duration_seconds: float
67
+ is_test: bool
68
+ is_verification: bool
69
+ tool: Optional[str]
70
+ stderr_preview: str
71
+ changed_files: List[str]
72
+ head_sha: Optional[str]
73
+ redacted: bool
74
+
75
+ @property
76
+ def failed(self) -> bool:
77
+ return self.status in _FAIL_STATUSES
78
+
79
+ @property
80
+ def passed(self) -> bool:
81
+ return self.status == COMMAND_STATUS_PASSED
82
+
83
+ @property
84
+ def is_verification_candidate(self) -> bool:
85
+ """A recognized test/build/lint/typecheck command, pass or fail.
86
+
87
+ ``is_verification`` is only true when such a command passed; this stays
88
+ true regardless of outcome so a failing check still counts.
89
+ """
90
+ return self.is_test or self.tool is not None
91
+
92
+
93
+ @dataclass
94
+ class RedToGreen:
95
+ command: str
96
+ failed_at: str
97
+ passed_at: str
98
+ window_seconds: float
99
+ changed_files: List[str]
100
+
101
+
102
+ @dataclass
103
+ class Derivation:
104
+ one_liner: Optional[str] = None
105
+ reproduce_command: Optional[str] = None
106
+ verify_command: Optional[str] = None
107
+ red_to_green: Optional[RedToGreen] = None
108
+ observed_error: Optional[str] = None
109
+ ruled_out: List[CommandRecord] = field(default_factory=list)
110
+ redaction_applied: bool = False
111
+ command_records: List[CommandRecord] = field(default_factory=list)
112
+
113
+
114
+ def _seconds(timestamp: str) -> float:
115
+ try:
116
+ return parse_iso8601(timestamp).timestamp()
117
+ except (ValueError, TypeError):
118
+ return 0.0
119
+
120
+
121
+ def _records(session: Session) -> List[CommandRecord]:
122
+ records: List[CommandRecord] = []
123
+ for event in session.command_events():
124
+ data = CommandData.from_dict(event.data)
125
+ records.append(
126
+ CommandRecord(
127
+ command=data.command,
128
+ timestamp=event.timestamp,
129
+ status=data.classification.status,
130
+ exit_code=data.exit_code,
131
+ duration_seconds=data.duration_seconds,
132
+ is_test=data.classification.is_test,
133
+ is_verification=data.classification.is_verification,
134
+ tool=data.classification.tool,
135
+ stderr_preview=data.stderr_preview,
136
+ changed_files=list(data.git_changed_files),
137
+ head_sha=data.git_head,
138
+ redacted=data.redacted,
139
+ )
140
+ )
141
+ records.sort(key=lambda r: _seconds(r.timestamp))
142
+ return records
143
+
144
+
145
+ def _session_span_seconds(session: Session) -> Optional[float]:
146
+ """Total span from the first to the last recorded event, in seconds."""
147
+ stamps = [_seconds(e.timestamp) for e in session.events if e.timestamp]
148
+ stamps = [s for s in stamps if s > 0]
149
+ if len(stamps) < 2:
150
+ return None
151
+ span = max(stamps) - min(stamps)
152
+ return span if span > 0 else None
153
+
154
+
155
+ def _detect_red_to_green(
156
+ session: Session, records: List[CommandRecord]
157
+ ) -> Optional[RedToGreen]:
158
+ first_fail: Optional[CommandRecord] = None
159
+ for rec in records:
160
+ if rec.is_verification_candidate and rec.failed:
161
+ first_fail = rec
162
+ break
163
+ if first_fail is None:
164
+ return None
165
+
166
+ fail_seconds = _seconds(first_fail.timestamp)
167
+ passed: Optional[CommandRecord] = None
168
+ for rec in records:
169
+ if (
170
+ rec.is_verification_candidate
171
+ and rec.passed
172
+ and _seconds(rec.timestamp) > fail_seconds
173
+ ):
174
+ passed = rec
175
+ break
176
+ if passed is None:
177
+ return None
178
+
179
+ # Correlate file changes across the window from per-event snapshots. Only
180
+ # meaningful inside a repo; reported as correlation, never as cause.
181
+ if not session.git.is_repo:
182
+ return None
183
+ pass_seconds = _seconds(passed.timestamp)
184
+ changed: List[str] = []
185
+ for rec in records:
186
+ ts = _seconds(rec.timestamp)
187
+ if fail_seconds <= ts <= pass_seconds:
188
+ for path in rec.changed_files:
189
+ if path not in changed:
190
+ changed.append(path)
191
+
192
+ return RedToGreen(
193
+ command=passed.command,
194
+ failed_at=first_fail.timestamp,
195
+ passed_at=passed.timestamp,
196
+ window_seconds=max(0.0, pass_seconds - fail_seconds),
197
+ changed_files=sorted(changed),
198
+ )
199
+
200
+
201
+ def _extract_observed_error(records: List[CommandRecord]) -> Optional[str]:
202
+ """Quote a single, real error line from a failed command's stderr.
203
+
204
+ Prefers the first failing verification command, then any failing command.
205
+ The text was already redacted at capture time.
206
+ """
207
+ failing = [r for r in records if r.failed and r.stderr_preview.strip()]
208
+ candidates = [r for r in failing if r.is_verification_candidate]
209
+ candidates += [r for r in failing if not r.is_verification_candidate]
210
+ for rec in candidates:
211
+ line = _pick_error_line(rec.stderr_preview)
212
+ if line:
213
+ if len(line) > _OBSERVED_ERROR_LIMIT:
214
+ return line[:_OBSERVED_ERROR_LIMIT].rstrip() + " ..."
215
+ return line
216
+ return None
217
+
218
+
219
+ def _pick_error_line(stderr: str) -> Optional[str]:
220
+ lines = [ln.strip() for ln in stderr.splitlines() if ln.strip()]
221
+ if not lines:
222
+ return None
223
+ # Prefer a line that looks like an assertion or error message.
224
+ for ln in reversed(lines):
225
+ lowered = ln.lower()
226
+ if (
227
+ "error" in lowered
228
+ or "assert" in lowered
229
+ or "exception" in lowered
230
+ or "traceback" in lowered
231
+ ):
232
+ return ln
233
+ # Otherwise the last non-empty line.
234
+ return lines[-1]
235
+
236
+
237
+ def _files_clause(session: Session, limit: int = 3) -> Optional[str]:
238
+ if not session.git.is_repo:
239
+ return None
240
+ files = list(session.summary.modified_files)
241
+ if not files:
242
+ return None
243
+ shown = files[:limit]
244
+ clause = ", ".join(shown)
245
+ extra = len(files) - len(shown)
246
+ if extra > 0:
247
+ clause += f", and {extra} more"
248
+ return clause
249
+
250
+
251
+ def _attempts_word(n: int) -> str:
252
+ return "attempt" if n == 1 else "attempts"
253
+
254
+
255
+ def _build_one_liner(
256
+ session: Session,
257
+ records: List[CommandRecord],
258
+ red_to_green: Optional[RedToGreen],
259
+ ) -> Optional[str]:
260
+ n = len(records)
261
+ span = _session_span_seconds(session)
262
+ duration = human_duration(span) if span is not None else None
263
+ files = _files_clause(session)
264
+
265
+ if red_to_green is not None:
266
+ parts = [f"Failing check `{red_to_green.command}` passed"]
267
+ parts.append(f"after {n} {_attempts_word(n)}")
268
+ if duration:
269
+ parts.append(f"over {duration}")
270
+ sentence = " ".join(parts)
271
+ if files:
272
+ sentence += f", changes touched {files}"
273
+ return sentence + "."
274
+
275
+ passed_verifications = [r for r in records if r.is_verification and r.passed]
276
+ failed_candidates = [r for r in records if r.is_verification_candidate and r.failed]
277
+
278
+ if passed_verifications:
279
+ cmd = passed_verifications[0].command
280
+ parts = [f"Verification `{cmd}` passed"]
281
+ parts.append(f"after {n} {_attempts_word(n)}")
282
+ if duration:
283
+ parts.append(f"over {duration}")
284
+ sentence = " ".join(parts)
285
+ if files:
286
+ sentence += f", changes touched {files}"
287
+ return sentence + "."
288
+
289
+ if failed_candidates:
290
+ cmd = failed_candidates[0].command
291
+ lead = f"Recorded {n} command {_attempts_word(n)}"
292
+ if duration:
293
+ lead += f" over {duration}"
294
+ return f"{lead}; verification `{cmd}` failed and none passed."
295
+
296
+ if n > 0:
297
+ lead = f"Recorded {n} command {_attempts_word(n)}"
298
+ if duration:
299
+ lead += f" over {duration}"
300
+ return f"{lead}; no verification commands were run."
301
+
302
+ notes = len(session.note_events())
303
+ if notes:
304
+ word = "note" if notes == 1 else "notes"
305
+ lead = f"Recorded {notes} {word}"
306
+ if duration:
307
+ lead += f" over {duration}"
308
+ return f"{lead}; no commands were run."
309
+
310
+ return None
311
+
312
+
313
+ def next_step_notes(session: Session) -> List[str]:
314
+ """Return recorded notes that read as forward-looking next steps.
315
+
316
+ Used by the handoff report so its next-steps section is drawn only from what
317
+ the human actually wrote, never inferred.
318
+ """
319
+ out: List[str] = []
320
+ for event in session.note_events():
321
+ text = (event.data or {}).get("text", "").strip()
322
+ if not text:
323
+ continue
324
+ if _NEXT_STEP_RE.search(text):
325
+ out.append(text)
326
+ return out
327
+
328
+
329
+ def derive(session: Session) -> Derivation:
330
+ records = _records(session)
331
+ red_to_green = _detect_red_to_green(session, records)
332
+
333
+ reproduce = next(
334
+ (r.command for r in records if r.is_verification_candidate and r.failed), None
335
+ )
336
+ verify = next(
337
+ (r.command for r in records if r.is_verification_candidate and r.passed), None
338
+ )
339
+
340
+ notes_redacted = any(
341
+ bool((e.data or {}).get("redacted")) for e in session.note_events()
342
+ )
343
+
344
+ return Derivation(
345
+ one_liner=_build_one_liner(session, records, red_to_green),
346
+ reproduce_command=reproduce,
347
+ verify_command=verify,
348
+ red_to_green=red_to_green,
349
+ observed_error=_extract_observed_error(records),
350
+ ruled_out=[r for r in records if r.failed],
351
+ redaction_applied=any(r.redacted for r in records) or notes_redacted,
352
+ command_records=records,
353
+ )