codevigil 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.
@@ -0,0 +1,236 @@
1
+ """Terminal renderer — ANSI-coloured full-redraw watch-mode output.
2
+
3
+ The ``Renderer`` protocol (``codevigil.types.Renderer``) exposes only
4
+ ``render``, ``render_error``, and ``close``. For the terminal output shape
5
+ documented in ``docs/design.md`` §CLI Modes → ``codevigil watch`` we need to
6
+ know when a 1 Hz aggregator tick starts and ends: the aggregator iterates
7
+ ``(meta, snapshots)`` pairs and calls ``render()`` once per session, but the
8
+ screen should be cleared exactly once per tick, not once per session.
9
+
10
+ This module extends the frozen protocol with two optional methods,
11
+ ``begin_tick()`` and ``end_tick()``, that the CLI layer (next phase) will
12
+ call around each tick's render loop. ``render()`` buffers the per-session
13
+ block internally; ``end_tick()`` flushes all buffered blocks to the output
14
+ stream in a single write, optionally preceded by an ANSI clear-screen
15
+ sequence when writing to a TTY with colour enabled.
16
+
17
+ Coloring uses raw ANSI escapes — no ``rich`` dependency — and is fully
18
+ stripped when ``use_color=False`` so tests can capture plain text by
19
+ routing through ``io.StringIO``. The clear-screen sequence is only emitted
20
+ when the stream is an actual TTY (``stream.isatty()``) *and* ``use_color``
21
+ is true; tests therefore receive clean append output even with colour on.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import sys
28
+ from dataclasses import dataclass, field
29
+ from typing import TextIO
30
+
31
+ from codevigil.errors import CodevigilError, ErrorLevel
32
+ from codevigil.types import MetricSnapshot, SessionMeta, SessionState, Severity
33
+
34
+ _OK_COLOR: str = "\x1b[32m"
35
+ _WARN_COLOR: str = "\x1b[33m"
36
+ _CRITICAL_COLOR: str = "\x1b[31m"
37
+ _RESET: str = "\x1b[0m"
38
+ _DIM: str = "\x1b[2m"
39
+ _BOLD: str = "\x1b[1m"
40
+ _CLEAR_SCREEN: str = "\x1b[2J\x1b[H"
41
+
42
+ _SEVERITY_WORD: dict[Severity, str] = {
43
+ Severity.OK: "OK",
44
+ Severity.WARN: "WARN",
45
+ Severity.CRITICAL: "CRIT",
46
+ }
47
+
48
+ _SEVERITY_COLOR: dict[Severity, str] = {
49
+ Severity.OK: _OK_COLOR,
50
+ Severity.WARN: _WARN_COLOR,
51
+ Severity.CRITICAL: _CRITICAL_COLOR,
52
+ }
53
+
54
+ _STATE_WORD: dict[SessionState, str] = {
55
+ SessionState.ACTIVE: "ACTIVE",
56
+ SessionState.STALE: "STALE",
57
+ SessionState.EVICTED: "EVICTED",
58
+ }
59
+
60
+ _STATE_COLOR: dict[SessionState, str] = {
61
+ SessionState.ACTIVE: _OK_COLOR,
62
+ SessionState.STALE: _DIM,
63
+ SessionState.EVICTED: _CRITICAL_COLOR,
64
+ }
65
+
66
+ _PARSE_HEALTH_METRIC: str = "parse_health"
67
+ _RULE: str = "─" * 70
68
+
69
+
70
+ @dataclass
71
+ class _SessionBlock:
72
+ """Buffered render output for one session in the current tick."""
73
+
74
+ banner_lines: list[str] = field(default_factory=list) # CRITICAL → above header
75
+ body_lines: list[str] = field(default_factory=list)
76
+ footer_lines: list[str] = field(default_factory=list) # WARN/ERROR → below body
77
+
78
+
79
+ class TerminalRenderer:
80
+ """ANSI full-redraw renderer for ``codevigil watch``."""
81
+
82
+ name: str = "terminal"
83
+
84
+ def __init__(
85
+ self,
86
+ *,
87
+ stream: TextIO | None = None,
88
+ show_experimental_badge: bool = True,
89
+ use_color: bool = True,
90
+ ) -> None:
91
+ self._stream: TextIO = stream if stream is not None else sys.stdout
92
+ self._show_experimental_badge: bool = show_experimental_badge
93
+ self._use_color: bool = use_color
94
+ self._blocks: dict[str, _SessionBlock] = {}
95
+ self._order: list[str] = []
96
+ self._parse_confidence: float = 1.0
97
+
98
+ # ---------------------------------------------------------- tick lifecycle
99
+
100
+ def begin_tick(self) -> None:
101
+ """Start a new tick — drop any previously buffered blocks."""
102
+
103
+ self._blocks = {}
104
+ self._order = []
105
+
106
+ def end_tick(self) -> None:
107
+ """Flush buffered blocks to the output stream in a single write."""
108
+
109
+ parts: list[str] = []
110
+ if self._use_color and self._stream_is_tty():
111
+ parts.append(_CLEAR_SCREEN)
112
+ parts.append(self._header_line())
113
+ parts.append("\n")
114
+ for session_id in self._order:
115
+ block = self._blocks[session_id]
116
+ for line in block.banner_lines:
117
+ parts.append(line)
118
+ parts.append("\n")
119
+ for line in block.body_lines:
120
+ parts.append(line)
121
+ parts.append("\n")
122
+ for line in block.footer_lines:
123
+ parts.append(line)
124
+ parts.append("\n")
125
+ self._stream.write("".join(parts))
126
+ self._stream.flush()
127
+ self._blocks = {}
128
+ self._order = []
129
+
130
+ # ---------------------------------------------------------------- render
131
+
132
+ def render(self, snapshots: list[MetricSnapshot], meta: SessionMeta) -> None:
133
+ """Buffer one session's block for the current tick."""
134
+
135
+ block = self._blocks.get(meta.session_id)
136
+ if block is None:
137
+ block = _SessionBlock()
138
+ self._blocks[meta.session_id] = block
139
+ self._order.append(meta.session_id)
140
+ block.body_lines.extend(self._session_body(snapshots, meta))
141
+
142
+ def render_error(self, err: CodevigilError, meta: SessionMeta | None) -> None:
143
+ """Route errors by level per design.md §Error Taxonomy → Levels and Routes.
144
+
145
+ INFO → silent (log file only). WARN → dim footer under the session
146
+ block. ERROR → bright (non-dim) footer under the session block.
147
+ CRITICAL → red banner above the session header.
148
+ """
149
+
150
+ if err.level is ErrorLevel.INFO:
151
+ return
152
+ session_id = meta.session_id if meta is not None else ""
153
+ block = self._blocks.get(session_id)
154
+ if block is None:
155
+ block = _SessionBlock()
156
+ self._blocks[session_id] = block
157
+ self._order.append(session_id)
158
+ text = f"{err.code}: {err.message}"
159
+ if err.level is ErrorLevel.WARN:
160
+ block.footer_lines.append(self._paint(f" ! {text}", _DIM))
161
+ elif err.level is ErrorLevel.ERROR:
162
+ block.footer_lines.append(self._paint(f" !! {text}", _WARN_COLOR + _BOLD))
163
+ elif err.level is ErrorLevel.CRITICAL:
164
+ block.banner_lines.append(self._paint(f"!!! CRITICAL {text}", _CRITICAL_COLOR + _BOLD))
165
+
166
+ def close(self) -> None:
167
+ """Flush the output stream. No persistent handles to release."""
168
+
169
+ with contextlib.suppress(ValueError):
170
+ # Stream already closed — nothing to flush.
171
+ self._stream.flush()
172
+
173
+ # --------------------------------------------------------------- helpers
174
+
175
+ def _stream_is_tty(self) -> bool:
176
+ isatty = getattr(self._stream, "isatty", None)
177
+ if not callable(isatty):
178
+ return False
179
+ try:
180
+ return bool(isatty())
181
+ except ValueError:
182
+ return False
183
+
184
+ def _paint(self, text: str, color: str) -> str:
185
+ if not self._use_color:
186
+ return text
187
+ return f"{color}{text}{_RESET}"
188
+
189
+ def _header_line(self) -> str:
190
+ parts: list[str] = [self._paint("codevigil", _BOLD)]
191
+ if self._show_experimental_badge:
192
+ parts.append(self._paint("[experimental thresholds]", _DIM + _WARN_COLOR))
193
+ parts.append(f"| parse_confidence: {self._parse_confidence:.2f}")
194
+ return " ".join(parts)
195
+
196
+ def _session_body(self, snapshots: list[MetricSnapshot], meta: SessionMeta) -> list[str]:
197
+ lines: list[str] = []
198
+ # Pick parse_confidence for the header from either meta or a
199
+ # parse_health snapshot if present.
200
+ pc = meta.parse_confidence
201
+ for snap in snapshots:
202
+ if snap.name == _PARSE_HEALTH_METRIC:
203
+ pc = snap.value
204
+ break
205
+ self._parse_confidence = pc
206
+ lines.append(self._session_line(meta))
207
+ lines.append(self._paint(_RULE, _DIM))
208
+ for snap in snapshots:
209
+ lines.append(self._metric_line(snap))
210
+ lines.append(self._paint(_RULE, _DIM))
211
+ return lines
212
+
213
+ def _session_line(self, meta: SessionMeta) -> str:
214
+ sid = meta.session_id[:8]
215
+ project = meta.project_name or meta.project_hash[:8]
216
+ duration = _format_duration((meta.last_event_time - meta.start_time).total_seconds())
217
+ state_word = _STATE_WORD[meta.state]
218
+ state_colored = self._paint(state_word, _STATE_COLOR[meta.state])
219
+ return f"session: {sid} | project: {project} | {duration} {state_colored}"
220
+
221
+ def _metric_line(self, snap: MetricSnapshot) -> str:
222
+ name = f"{snap.name:<18}"
223
+ value = f"{snap.value:>6.1f}"
224
+ sev_word = _SEVERITY_WORD[snap.severity]
225
+ sev_colored = self._paint(sev_word, _SEVERITY_COLOR[snap.severity])
226
+ label = f"[{snap.label}]" if snap.label else ""
227
+ return f" {name} {value} {sev_colored} {label}"
228
+
229
+
230
+ def _format_duration(seconds: float) -> str:
231
+ total = int(max(0.0, seconds))
232
+ minutes, secs = divmod(total, 60)
233
+ return f"{minutes}m {secs:02d}s"
234
+
235
+
236
+ __all__ = ["TerminalRenderer"]
codevigil/types.py ADDED
@@ -0,0 +1,189 @@
1
+ """Core vocabulary: Event, MetricSnapshot, SessionMeta, Collector/Renderer protocols.
2
+
3
+ Everything in this module is a *frozen contract*. Once Phase 1 lands on main
4
+ these names, shapes, and semantics may not change without an explicit
5
+ breaking-change PR, because every downstream subsystem (parser, watcher,
6
+ aggregator, collectors, renderers, CLI) imports from here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Any, Protocol, runtime_checkable
16
+
17
+ from codevigil.errors import CodevigilError, ErrorLevel, ErrorSource, record
18
+
19
+
20
+ class EventKind(Enum):
21
+ TOOL_CALL = "tool_call"
22
+ TOOL_RESULT = "tool_result"
23
+ ASSISTANT_MESSAGE = "assistant"
24
+ USER_MESSAGE = "user"
25
+ THINKING = "thinking"
26
+ SYSTEM = "system"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Event:
31
+ """One typed record emitted by the parser.
32
+
33
+ ``payload`` is deliberately unstructured at the type level — the
34
+ per-``EventKind`` schemas live in ``docs/design.md`` §Payload Schemas by
35
+ EventKind and are enforced by ``safe_get`` at read time, not by a
36
+ dataclass tree. This keeps the kind space open for additive growth.
37
+ """
38
+
39
+ timestamp: datetime
40
+ session_id: str
41
+ kind: EventKind
42
+ payload: dict[str, Any]
43
+
44
+
45
+ class Severity(Enum):
46
+ OK = "ok"
47
+ WARN = "warn"
48
+ CRITICAL = "critical"
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class MetricSnapshot:
53
+ """Single metric reading produced by a collector on each ``snapshot()``.
54
+
55
+ ``value`` is always a float so every metric has exactly one scalar the
56
+ renderer can threshold, trend, and compare. Structured breakdowns go in
57
+ ``detail`` so the scalar contract stays simple.
58
+ """
59
+
60
+ name: str
61
+ value: float
62
+ label: str
63
+ severity: Severity = Severity.OK
64
+ detail: dict[str, Any] | None = None
65
+
66
+
67
+ class SessionState(Enum):
68
+ ACTIVE = "active"
69
+ STALE = "stale"
70
+ EVICTED = "evicted"
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class SessionMeta:
75
+ """Aggregator-owned view of a session handed to renderers on every tick."""
76
+
77
+ session_id: str
78
+ project_hash: str
79
+ project_name: str | None
80
+ file_path: Path
81
+ start_time: datetime
82
+ last_event_time: datetime
83
+ event_count: int
84
+ parse_confidence: float
85
+ state: SessionState
86
+
87
+
88
+ @runtime_checkable
89
+ class Collector(Protocol):
90
+ """Contract every metric collector must honor.
91
+
92
+ The ``complexity`` attribute is a human-readable big-O string documented
93
+ per §Complexity Honesty in the design. Snapshots are pure functions of
94
+ collector state and are idempotent.
95
+ """
96
+
97
+ name: str
98
+ complexity: str
99
+
100
+ def ingest(self, event: Event) -> None: ...
101
+
102
+ def snapshot(self) -> MetricSnapshot: ...
103
+
104
+ def reset(self) -> None: ...
105
+
106
+
107
+ @runtime_checkable
108
+ class Renderer(Protocol):
109
+ """Contract every renderer must honor."""
110
+
111
+ name: str
112
+
113
+ def render(self, snapshots: list[MetricSnapshot], meta: SessionMeta) -> None: ...
114
+
115
+ def render_error(self, err: CodevigilError, meta: SessionMeta | None) -> None: ...
116
+
117
+ def close(self) -> None: ...
118
+
119
+
120
+ _MISSING = object()
121
+
122
+
123
+ def safe_get(
124
+ payload: dict[str, Any],
125
+ key: str,
126
+ default: Any,
127
+ expected: type | None = None,
128
+ *,
129
+ required: bool = False,
130
+ source: ErrorSource = ErrorSource.PARSER,
131
+ event_kind: str | None = None,
132
+ ) -> Any:
133
+ """Typed payload lookup that routes drift through the error channel.
134
+
135
+ Returns ``payload[key]`` when present and (optionally) type-matching the
136
+ ``expected`` type. Emits a WARN ``CodevigilError`` to the process-wide
137
+ error channel on missing-but-required or type-mismatch cases, so every
138
+ silent ``KeyError`` becomes a counted, observable drift signal the
139
+ parser's ``parse_confidence`` meter can pick up.
140
+ """
141
+
142
+ value: Any = payload.get(key, _MISSING)
143
+ if value is _MISSING:
144
+ if required:
145
+ record(
146
+ CodevigilError(
147
+ level=ErrorLevel.WARN,
148
+ source=source,
149
+ code="safe_get.missing_required",
150
+ message=f"required key {key!r} missing from payload",
151
+ context={"key": key, "event_kind": event_kind},
152
+ )
153
+ )
154
+ return default
155
+ if expected is not None and not isinstance(value, expected):
156
+ record(
157
+ CodevigilError(
158
+ level=ErrorLevel.WARN,
159
+ source=source,
160
+ code="safe_get.type_mismatch",
161
+ message=(
162
+ f"key {key!r} has type {type(value).__name__}, expected {expected.__name__}"
163
+ ),
164
+ context={
165
+ "key": key,
166
+ "expected": expected.__name__,
167
+ "actual": type(value).__name__,
168
+ "event_kind": event_kind,
169
+ },
170
+ )
171
+ )
172
+ return default
173
+ return value
174
+
175
+
176
+ # Re-export field so downstream phases that need default_factory can pull it
177
+ # from types without introducing a second dataclasses import.
178
+ __all__ = [
179
+ "Collector",
180
+ "Event",
181
+ "EventKind",
182
+ "MetricSnapshot",
183
+ "Renderer",
184
+ "SessionMeta",
185
+ "SessionState",
186
+ "Severity",
187
+ "field",
188
+ "safe_get",
189
+ ]