Skip to main content

task_runs/
beholders.rs

1//! Beholder framework — observer-side parsers and rewriters for structured output.
2//!
3//! A *beholder* attaches to a `TaskRun` at spawn time and turns raw output
4//! chunks into structured `Event` rows. There are two modes:
5//!
6//! - **Rewriter:** mutates `argv` before the subprocess starts (e.g. cargo
7//!   gets `--message-format=json-render-diagnostics`).
8//! - **Parser:** reads human-formatted output and scrapes events out of it.
9//!
10//! # Integration point
11//!
12//! ```ignore
13//! let result = registry.attach(raw_cmd, &BeholderSelect::Auto, tty_attached);
14//! // spawn the process with result.argv (may be rewritten)
15//! // for each chunk captured:
16//! if let Some(b) = result.beholder.as_mut() {
17//!     for event in b.parse_chunk(&chunk) {
18//!         store.append_event(run_id, ...);
19//!     }
20//! }
21//! // persist result.status on the run:
22//! store.update_beholder_status(run_id, &result.status)?;
23//! ```
24//!
25//! @yah:ticket(R739-B8, "The cargo beholder splices --message-format onto the LAST stage of a relocated shell line, destroying the command")
26//! @yah:at(2026-08-29T00:50:35Z)
27//! @yah:status(review)
28//! @yah:assignee(agent:bundle-anthropic-ashguard)
29//! @yah:parent(R739)
30//! @yah:severity(high)
31//! @yah:gotcha("REPRODUCED TWICE LIVE in this session, not inferred. `cargo check -p yah-agent-tools --lib 2>&1 | tail -15` relocated by R739-F4 into `yah build run` came back with: tail: unrecognized option `--message-format=json-render-diagnostics'. The build never ran.")
32//! @yah:gotcha("MECHANISM, end to end. build_run.rs:120 submits the agent's WHOLE bash line as TaskRunParams.cmd. driver.rs:472 calls registry.attach(cmd, ...). beholders.rs resolve_argv whitespace-tokenizes that line (tokenize is split_ascii_whitespace and its own doc says it does not handle shell quoting). CargoBeholderFactory::matches sees argv[0]==cargo plus a diag subcommand and attaches; its Rewriter adjust_argv does argv.push(--message-format=json-render-diagnostics) - onto the END of the token list, which for a piped line is tail's argv. driver.rs then rejoins with attach.argv.join(\" \") and hands that to sh -c.")
33//! @yah:gotcha("WHY IT IS THE COMMON CASE, NOT A CORNER. R767-S7 measured 76.5% of real bash calls as compound (27908/36479). The only thing sparing most of them is that matches() needs argv[0]==cargo verbatim, so a `cd x && cargo ...` or `RUSTFLAGS=y cargo ...` line declines by accident. A line that STARTS with cargo and pipes - the single commonest build shape in this camp - hits it every time.")
34//! @yah:gotcha("ANOTHER AGENT ALREADY HIT THIS AND WORKED AROUND IT rather than filing it: see the @yah:gotcha on oss/roadcase/crates/roadcase-registry/src/lib.rs advising `run cargo from a script that redirects to a log file`. That is a second independent sighting.")
35//! @yah:handoff("FIXED IN SOURCE, NOT YET LIVE. Two changes, both in oss/qed/crates/task-runs. (1) beholders.rs: BeholderRegistry::attach now returns bytes-only with status declined:auto reason=\"compound-command\" when is_single_simple_command(raw_cmd) is false - a character scan for | & ; < > ( ) ` $ and newline. Deliberately over-strict and deliberately not a shell parser: a false positive costs one run its structured events, a false negative costs that run its command. Force does not override it, because forcing a rewriter onto a pipeline breaks the command just as thoroughly as Auto would.")
36//! @yah:handoff("(2) driver.rs spawn_run: effective_cmd is now the caller's `cmd` verbatim unless a beholder ACTUALLY rewrote it (attach.status.rewrite_added non-empty). AttachResult.argv is populated on every run - it is resolve_argv(cmd) even under BeholderSelect::None - so joining it unconditionally put EVERY task.run command through a whitespace normalization nobody asked for. Two separate corruptions fixed by that: embedded newlines became spaces (a two-line cmd silently became one nonsense command), and resolve_argv's bunx/npx/pnpm wrapper stripping - which exists so a beholder's matches() sees the bare tool - reached the actual spawn.")
37//! @yah:handoff("VERIFIED: cd oss/qed && cargo test -p task-runs --lib -> 252 passed, 0 failed (was 246; 6 new tests, 4 in beholders.rs and 2 in driver.rs, each named for the failure it pins).")
38//! @yah:handoff("FOUND BY R739-S2 while trying to verify an unrelated edit. Filed and fixed rather than handed on, because it is a live camp-wide breakage on this relay's own rail.")
39//! @yah:next("OPERATOR ACTION REQUIRED, and it is the only thing between this fix and the camp: the running daemon is 0.8.28+cc33e693 and still carries the old task-runs. Rebuild the desktop and restart yah.app. Until then every relocated bash line that STARTS with cargo and contains a pipe still dies. Sessions can dodge it meanwhile by prefixing `cd <dir> && ` - that makes argv[0] not-cargo and the beholder declines.")
40//! @yah:verify("cd oss/qed && cargo test -p task-runs --lib  # 252/252 green")
41//! @yah:verify("After the daemon restart: a bare `cargo check -p yah-agent-tools --lib 2>&1 | tail -15` must produce cargo's own output, not tail's usage message.")
42//! @yah:verify("Check TaskRunMeta.beholder_status on such a run reads declined:auto reason=\"compound-command\" rather than attached:cargo@1.38.")
43//! @yah:handoff("Fix landed in source and unit-tested; awaiting the daemon rebuild that makes it live. Full mechanism, both changes and the verification are in this ticket's existing handoff/gotcha entries and in W302 section B.1.11.")
44//! @yah:gotcha("THIRD LIVE SIGHTING 2026-08-28 (R739-S3, session:42c7df73): relocated cargo check plus a tail pipe returned tail usage output. The skew banner named the daemon 0.8.28+cc33e693 - the exact stale build this ticket calls out. Operator rebuild+restart still outstanding.")
45//! @yah:gotcha("INTERACTION WITH R719-B8, new datum from that sighting: a destroyed command exits in milliseconds, but having been auto-backgrounded it still holds cargo-target for the watcher full 120s quiet window. The dead run was listed as queued to my own next call 1m28s after it exited. So each B8-destroyed build inflates every peer queue-position note by one slot for two minutes - the bug degrades the rung-1 note camp-wide, not just its own run.")
46
47use crate::types::{BeholderStatus, ChunkRef, Event, EventSource, Level, OutputChunk};
48
49// ─── ToolVersionRange ─────────────────────────────────────────────────────────
50
51/// Declared support range for the *tool's* structured-output schema (not the
52/// beholder's own version). Used to surface `unknown_format` when drift is
53/// detected at parse time.
54///
55/// Strings are the underlying tool's version: e.g. `"1.38.0"` for cargo
56/// (when `--message-format=json-render-diagnostics` was stabilised).
57/// `None` means "no known lower/upper bound".
58#[derive(Debug, Clone)]
59pub struct ToolVersionRange {
60    pub min: Option<&'static str>,
61    pub max: Option<&'static str>,
62}
63
64// ─── BeholderMode ─────────────────────────────────────────────────────────────
65
66/// Whether a beholder modifies the invocation (rewriter) or reads human output
67/// (parser).
68#[derive(Clone)]
69pub enum BeholderMode {
70    /// Rewrites argv before spawn to enable structured output.
71    Rewriter {
72        /// Applied to argv in-place when the beholder attaches.
73        adjust_argv: fn(&mut Vec<String>),
74    },
75    /// Rewrites argv by appending a fixed set of args. Used by user-defined
76    /// drop-in beholders whose `add_args` are loaded from TOML at runtime.
77    DynamicRewriter {
78        /// Args to append when not already present in argv.
79        add_args: Vec<String>,
80    },
81    /// Reads the tool's human-formatted output without rewriting argv.
82    Parser,
83}
84
85// ─── BeholderSelect ───────────────────────────────────────────────────────────
86
87/// Caller-supplied attachment policy for `task.run`.
88#[derive(Debug, Clone, Default)]
89pub enum BeholderSelect {
90    /// Walk the registry in priority order; first matching beholder wins.
91    #[default]
92    Auto,
93    /// Pin to a specific beholder by name; bypass `matches` check.
94    ///
95    /// The beholder runs even if it would normally decline (e.g. explicit
96    /// `--message-format=human` for cargo). Recorded as `forced-against-flags`
97    /// on `beholder_status` in that case.
98    Force(String),
99    /// Bytes-only, regardless of registry contents.
100    None,
101}
102
103// ─── AttachResult ─────────────────────────────────────────────────────────────
104
105/// Returned by [`BeholderRegistry::attach`].
106pub struct AttachResult {
107    /// Attached per-run beholder instance, if any.
108    pub beholder: Option<Box<dyn Beholder>>,
109    /// Status string to store on `TaskRunMeta.beholder_status`.
110    pub status: BeholderStatus,
111    /// Argv to pass to the subprocess launcher.
112    ///
113    /// When a `Rewriter` beholder attaches, this reflects the adjusted argv.
114    /// Otherwise identical to the input tokens from `raw_cmd`.
115    pub argv: Vec<String>,
116}
117
118// ─── BeholderFactory ──────────────────────────────────────────────────────────
119
120/// Static descriptor + factory for a beholder type. Held in the registry.
121///
122/// `create()` produces a fresh, independent per-run instance each time.
123/// Implementors should be stateless — all per-run state belongs in the
124/// instance returned by `create`.
125pub trait BeholderFactory: Send + Sync {
126    fn name(&self) -> &'static str;
127    fn version(&self) -> &'static str;
128    /// Return `true` if this beholder handles the given invocation.
129    ///
130    /// Receives the *resolved* argv (wrappers stripped). May inspect argv to
131    /// detect user-set conflicting flags (e.g. `--message-format=human`) and
132    /// return `false` to decline gracefully.
133    fn matches(&self, resolved_argv: &[String]) -> bool;
134    fn mode(&self) -> BeholderMode;
135    /// Create a fresh per-run instance.
136    fn create(&self) -> Box<dyn Beholder>;
137    /// Declared support range for the underlying tool's structured output format.
138    ///
139    /// Informational: surfaced in `beholder_status` when format drift is detected
140    /// so agents can see which version window the beholder was built for.
141    /// `None` means the beholder makes no version claim.
142    fn tool_version_range(&self) -> Option<ToolVersionRange> { None }
143}
144
145// ─── Beholder ─────────────────────────────────────────────────────────────────
146
147/// Per-run stateful chunk processor.
148///
149/// The instance is private to a single `TaskRun`. `parse_chunk` is called for
150/// every captured chunk in arrival order and may produce zero or more structured
151/// events. Implementations must be incremental — buffering everything to EOF
152/// defeats the lossless-during-execution property.
153///
154/// For tools that dump a single JSON document at exit (e.g. ESLint, Biome),
155/// implement `parse_chunk` as a simple buffer accumulator and do all parsing
156/// in `on_done`, which the driver calls once the PTY reader exits.
157pub trait Beholder: Send {
158    fn name(&self) -> &'static str;
159    fn version(&self) -> &'static str;
160    fn mode(&self) -> BeholderMode;
161    /// Extract structured events from one output chunk.
162    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event>;
163    /// Called once after the last chunk has been delivered (PTY EOF).
164    ///
165    /// Use this for tools that write a single JSON document at exit rather than
166    /// streaming line-by-line. The driver passes the run id and a final
167    /// timestamp so the returned events can be fully populated.
168    ///
169    /// Default: returns no events (correct for streaming beholders).
170    fn on_done(&mut self, _run_id: &crate::types::TaskRunId, _offset_ms: u32) -> Vec<Event> {
171        Vec::new()
172    }
173    /// Returns a reason string if the beholder detected that the tool's output
174    /// format is unrecognized (schema drift). When `Some`, the driver detaches
175    /// this beholder and updates `beholder_status` to `unknown_format:name`.
176    ///
177    /// Default: `None` — format OK or not yet probed.
178    fn unknown_format_reason(&self) -> Option<&str> { None }
179}
180
181// ─── BeholderRegistry ────────────────────────────────────────────────────────
182
183/// Registry of available beholders, consulted at `task.run` time.
184///
185/// Entries are tried in insertion (priority) order. Earlier registrations have
186/// higher priority when multiple beholders' `matches` predicates would fire.
187pub struct BeholderRegistry {
188    entries: Vec<Box<dyn BeholderFactory>>,
189}
190
191impl BeholderRegistry {
192    pub fn new() -> Self {
193        Self { entries: Vec::new() }
194    }
195
196    /// Register a beholder factory. Earlier entries have higher priority.
197    pub fn register(&mut self, factory: Box<dyn BeholderFactory>) {
198        self.entries.push(factory);
199    }
200
201    /// Determine and attach a beholder for the given invocation.
202    ///
203    /// - Tokenizes `raw_cmd` and strips wrapper binaries (bunx, npx, pnpm, npm exec).
204    /// - `tty_attached` signals that a human-facing terminal tile is watching:
205    ///   `Rewriter` beholders decline in `Auto` mode to avoid clobbering human
206    ///   output. `Force` always attaches but records `forced-against-tty`.
207    /// - Behaviour depends on `select`:
208    ///   - `Auto` — walk in priority order, first `matches` hit wins.
209    ///   - `Force(name)` — find by name; bypass `matches` (records
210    ///     `forced-against-flags` if the beholder would have declined, or
211    ///     `forced-against-tty` if a TTY is attached and mode is Rewriter).
212    ///   - `None` — bytes-only; no beholder attached.
213    /// - If a `Rewriter` beholder attaches, its `adjust_argv` is applied to the
214    ///   returned `AttachResult.argv` and the diff is surfaced on `status`.
215    pub fn attach(&self, raw_cmd: &str, select: &BeholderSelect, tty_attached: bool) -> AttachResult {
216        let mut argv = resolve_argv(raw_cmd);
217
218        // A shell line is not an argv, and this registry can only reason about
219        // an argv. `resolve_argv` whitespace-splits whatever it is given, so a
220        // `Rewriter` beholder's `adjust_argv` appends its flag to the LAST
221        // token of the line — which for `cargo check … 2>&1 | tail -12` is
222        // `tail`'s argument list, not cargo's. Observed live: `tail:
223        // unrecognized option '--message-format=json-render-diagnostics'`,
224        // i.e. the beholder does not merely mis-instrument the run, it
225        // destroys the command.
226        //
227        // Splitting the line properly is not the fix. Even placed correctly,
228        // structured JSON on stdout would flow into the caller's own `| rg …`
229        // and give them nothing they asked for; and this crate deliberately
230        // carries no shell parser. So the honest answer for anything that is
231        // not one simple command is bytes-only, recorded as a decline rather
232        // than as `none:auto` so the reason is legible on the run.
233        if !is_single_simple_command(raw_cmd) && !matches!(select, BeholderSelect::None) {
234            return AttachResult {
235                beholder: None,
236                status: BeholderStatus::declined("auto", "compound-command"),
237                argv,
238            };
239        }
240
241        match select {
242            BeholderSelect::None => AttachResult {
243                beholder: None,
244                status: BeholderStatus::none_explicit(),
245                argv,
246            },
247
248            BeholderSelect::Force(name) => {
249                match self.entries.iter().find(|e| e.name() == name.as_str()) {
250                    None => AttachResult {
251                        beholder: None,
252                        status: BeholderStatus::none_auto(),
253                        argv,
254                    },
255                    Some(factory) => {
256                        let would_decline = !factory.matches(&argv);
257                        let is_rewriter = matches!(factory.mode(), BeholderMode::Rewriter { .. } | BeholderMode::DynamicRewriter { .. });
258                        let base_status = if would_decline {
259                            BeholderStatus::forced_against_flags(factory.name(), factory.version())
260                        } else if tty_attached && is_rewriter {
261                            BeholderStatus::forced_against_tty(factory.name(), factory.version())
262                        } else {
263                            BeholderStatus::forced(factory.name(), factory.version())
264                        };
265                        let added = rewrite_if_rewriter(factory.mode(), &mut argv);
266                        let status = base_status.with_rewrite(added);
267                        AttachResult { beholder: Some(factory.create()), status, argv }
268                    }
269                }
270            }
271
272            BeholderSelect::Auto => {
273                for factory in &self.entries {
274                    if factory.matches(&argv) {
275                        let is_rewriter = matches!(factory.mode(), BeholderMode::Rewriter { .. } | BeholderMode::DynamicRewriter { .. });
276                        // TTY-attached: Rewriter beholders decline to preserve human output.
277                        if tty_attached && is_rewriter {
278                            return AttachResult {
279                                beholder: None,
280                                status: BeholderStatus::declined(factory.name(), "tty-attached"),
281                                argv,
282                            };
283                        }
284                        let added = rewrite_if_rewriter(factory.mode(), &mut argv);
285                        let status = BeholderStatus::attached(factory.name(), factory.version())
286                            .with_rewrite(added);
287                        return AttachResult {
288                            beholder: Some(factory.create()),
289                            status,
290                            argv,
291                        };
292                    }
293                }
294                AttachResult {
295                    beholder: None,
296                    status: BeholderStatus::none_auto(),
297                    argv,
298                }
299            }
300        }
301    }
302
303    pub fn len(&self) -> usize {
304        self.entries.len()
305    }
306
307    pub fn is_empty(&self) -> bool {
308        self.entries.is_empty()
309    }
310}
311
312impl Default for BeholderRegistry {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318// ─── helpers ─────────────────────────────────────────────────────────────────
319
320/// Apply argv rewriting if `mode` is `Rewriter` or `DynamicRewriter`, and
321/// return the args that were added. Returns an empty `Vec` for `Parser` mode
322/// or when the rewriter added nothing.
323fn rewrite_if_rewriter(mode: BeholderMode, argv: &mut Vec<String>) -> Vec<String> {
324    match mode {
325        BeholderMode::Rewriter { adjust_argv } => {
326            let before = argv.clone();
327            adjust_argv(argv);
328            argv.iter().filter(|a| !before.contains(a)).cloned().collect()
329        }
330        BeholderMode::DynamicRewriter { add_args } => {
331            let mut added = Vec::new();
332            for arg in add_args {
333                if !argv.contains(&arg) {
334                    argv.push(arg.clone());
335                    added.push(arg);
336                }
337            }
338            added
339        }
340        BeholderMode::Parser => Vec::new(),
341    }
342}
343
344// ─── argv resolution ─────────────────────────────────────────────────────────
345
346/// Tokenize `raw_cmd` and strip common wrapper binaries so beholders see the
347/// bare tool name as `argv[0]`.
348///
349/// Wrappers stripped:
350/// - `bunx` / `npx` — drop the wrapper token.
351/// - `pnpm` / `npm exec` — drop the wrapper and optional `exec` subcommand.
352pub fn resolve_argv(raw_cmd: &str) -> Vec<String> {
353    let mut argv: Vec<String> = tokenize(raw_cmd);
354    loop {
355        match argv.first().map(String::as_str) {
356            Some("bunx") | Some("npx") => {
357                argv.remove(0);
358            }
359            Some("pnpm") | Some("npm") => {
360                argv.remove(0);
361                if argv.first().map(String::as_str) == Some("exec") {
362                    argv.remove(0);
363                }
364                break;
365            }
366            _ => break,
367        }
368    }
369    argv
370}
371
372/// Split on ASCII whitespace. Sufficient for well-formed command strings;
373/// does not handle shell quoting.
374fn tokenize(s: &str) -> Vec<String> {
375    s.split_ascii_whitespace().map(str::to_owned).collect()
376}
377
378/// Is `raw_cmd` one simple command, i.e. is treating it as an argv sound?
379///
380/// Deliberately a character scan and deliberately over-strict: this crate has
381/// no shell parser and should not grow one to answer a safety question. Every
382/// false positive costs one run its structured events; every false negative
383/// costs that run its *command*, because a `Rewriter` beholder will splice a
384/// flag into whatever program happens to be last on the line.
385///
386/// `$` is in the set for command substitution (`$(…)`) rather than for plain
387/// variable expansion, which would be harmless — but distinguishing the two
388/// needs the parser this function exists to avoid.
389fn is_single_simple_command(raw_cmd: &str) -> bool {
390    !raw_cmd
391        .chars()
392        .any(|c| matches!(c, '|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' | '$' | '\n'))
393}
394
395// ─── Cargo beholder ───────────────────────────────────────────────────────────
396
397/// Cargo subcommands that produce rustc diagnostic output.
398const CARGO_DIAG_SUBCOMMANDS: &[&str] = &[
399    "check", "build", "test", "clippy", "run", "fix", "doc", "bench", "publish",
400];
401
402/// Factory for the bundled cargo beholder (Tier 1.5, Rewriter mode).
403///
404/// Matches `cargo <sub>` invocations for diagnostic-producing subcommands and
405/// rewrites argv to add `--message-format=json-render-diagnostics`, which
406/// makes cargo emit one JSON object per line rather than human-formatted text.
407///
408/// Declines when the user already specified `--message-format` (respects
409/// explicit intent; see R070-T5 for TTY-aware behaviour rules).
410pub struct CargoBeholderFactory;
411
412impl BeholderFactory for CargoBeholderFactory {
413    fn name(&self) -> &'static str { "cargo" }
414    fn version(&self) -> &'static str { "1.38" }
415
416    fn matches(&self, argv: &[String]) -> bool {
417        if argv.first().map(String::as_str) != Some("cargo") {
418            return false;
419        }
420        let has_diag_sub = argv.iter().skip(1)
421            .any(|a| CARGO_DIAG_SUBCOMMANDS.contains(&a.as_str()));
422        if !has_diag_sub {
423            return false;
424        }
425        // Decline when the user explicitly chose a message format.
426        !argv.iter().any(|a| a.starts_with("--message-format"))
427    }
428
429    fn mode(&self) -> BeholderMode {
430        BeholderMode::Rewriter {
431            adjust_argv: |argv| {
432                argv.push("--message-format=json-render-diagnostics".to_owned());
433            },
434        }
435    }
436
437    fn create(&self) -> Box<dyn Beholder> {
438        Box::new(CargoBeholder::default())
439    }
440
441    fn tool_version_range(&self) -> Option<ToolVersionRange> {
442        // --message-format=json-render-diagnostics was stabilised in cargo 1.38.0.
443        // No upper bound — the format has been additive since stabilisation.
444        Some(ToolVersionRange { min: Some("1.38.0"), max: None })
445    }
446}
447
448/// After this many JSON-shaped lines fail to match the `CargoLine` schema
449/// (missing `reason` field, etc.) without a single recognizable line, the
450/// beholder declares `unknown_format` and stops emitting events.
451const FORMAT_PROBE_LIMIT: u8 = 5;
452
453/// Per-run cargo JSON parser.
454///
455/// Buffers incoming bytes and extracts complete newline-delimited JSON records
456/// as they arrive. Non-JSON lines (blank lines, unexpected text) are silently
457/// skipped so a stray progress line never stalls the stream.
458///
459/// If the first [`FORMAT_PROBE_LIMIT`] JSON-shaped lines all fail to match the
460/// expected cargo schema (missing `reason` field), `unknown_format_reason`
461/// returns a non-`None` value and `parse_chunk` stops emitting events.
462pub struct CargoBeholder {
463    buf: Vec<u8>,
464    /// JSON-object lines seen that don't match the `CargoLine` schema.
465    /// Only counted before a recognizable cargo line has been seen.
466    json_lines_unrecognized: u8,
467    /// Set once a line successfully parses as `CargoLine` (has `reason` field).
468    /// After this point unrecognized lines are silently dropped (forward compat).
469    recognized_line_seen: bool,
470    /// Set when `json_lines_unrecognized >= FORMAT_PROBE_LIMIT` and no cargo
471    /// line has been seen yet — indicates the output format is unrecognized.
472    unknown_format: Option<String>,
473}
474
475impl Default for CargoBeholder {
476    fn default() -> Self {
477        Self {
478            buf: Vec::new(),
479            json_lines_unrecognized: 0,
480            recognized_line_seen: false,
481            unknown_format: None,
482        }
483    }
484}
485
486impl Beholder for CargoBeholder {
487    fn name(&self) -> &'static str { "cargo" }
488    fn version(&self) -> &'static str { "1.38" }
489    fn mode(&self) -> BeholderMode {
490        BeholderMode::Rewriter {
491            adjust_argv: |argv| {
492                argv.push("--message-format=json-render-diagnostics".to_owned());
493            },
494        }
495    }
496
497    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
498        if self.unknown_format.is_some() {
499            return Vec::new();
500        }
501        self.buf.extend_from_slice(&chunk.bytes);
502        let mut events = Vec::new();
503        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
504            let line: Vec<u8> = self.buf.drain(..=nl).collect();
505            // Strip trailing \n (and optional \r for Windows cargo output).
506            let line = line.trim_ascii_end();
507            if line.is_empty() || line[0] != b'{' {
508                continue;
509            }
510            let Ok(s) = std::str::from_utf8(line) else { continue };
511
512            // Try to parse as a CargoLine (requires `reason: String`).
513            match serde_json::from_str::<CargoLine>(s) {
514                Ok(cargo_line) => {
515                    self.recognized_line_seen = true;
516                    if let Some(ev) = parse_cargo_line_inner(&cargo_line, chunk) {
517                        events.push(ev);
518                    }
519                }
520                Err(_) => {
521                    // JSON object but doesn't fit CargoLine schema.
522                    // Only counts toward the probe threshold before we've seen
523                    // a recognized line; after that it's just forward compat.
524                    if !self.recognized_line_seen {
525                        self.json_lines_unrecognized =
526                            self.json_lines_unrecognized.saturating_add(1);
527                        if self.json_lines_unrecognized >= FORMAT_PROBE_LIMIT {
528                            self.unknown_format = Some(format!(
529                                "no recognizable cargo JSON lines in first {} JSON-object lines",
530                                FORMAT_PROBE_LIMIT
531                            ));
532                            return Vec::new();
533                        }
534                    }
535                }
536            }
537        }
538        events
539    }
540
541    fn unknown_format_reason(&self) -> Option<&str> {
542        self.unknown_format.as_deref()
543    }
544}
545
546/// Construct a [`BeholderRegistry`] pre-loaded with all bundled beholders.
547pub fn default_registry() -> BeholderRegistry {
548    let mut r = BeholderRegistry::new();
549    r.register(Box::new(CargoBeholderFactory));
550    r.register(Box::new(TscBeholderFactory));
551    r.register(Box::new(EslintBeholderFactory));
552    r.register(Box::new(BiomeBeholderFactory));
553    r.register(Box::new(VitestBeholderFactory));
554    r.register(Box::new(JestBeholderFactory));
555    r.register(Box::new(BunTestBeholderFactory));
556    r.register(Box::new(PytestBeholderFactory));
557    r.register(Box::new(ViteBuildBeholderFactory));
558    r
559}
560
561/// Construct a [`BeholderRegistry`] with bundled beholders plus any user-defined
562/// beholders loaded from `user_dir` (typically `~/.yah/beholders/`).
563///
564/// User beholders are appended after bundled ones, so bundled beholders have
565/// higher priority when both would match the same command. Malformed or
566/// unreadable TOML files in `user_dir` are skipped silently.
567pub fn registry_with_user_beholders(user_dir: Option<&std::path::Path>) -> BeholderRegistry {
568    let mut r = default_registry();
569    if let Some(dir) = user_dir {
570        for factory in crate::user_beholders::load_user_beholders(dir) {
571            r.register(factory);
572        }
573    }
574    r
575}
576
577// ─── tsc beholder ─────────────────────────────────────────────────────────────
578
579/// Factory for the bundled tsc beholder (Tier 1.5, Rewriter + Parser).
580///
581/// Matches `tsc` invocations (bare or via wrapper: `bunx tsc`, `pnpm tsc`,
582/// `npx tsc`) and rewrites argv to add `--pretty=false` when not already set,
583/// enabling the machine-parseable diagnostic format:
584///
585/// ```text
586/// src/foo.ts(10,5): error TS2345: Argument of type 'string' is not assignable…
587/// Found 1 error.
588/// ```
589///
590/// Declines when:
591/// - `--pretty` or `--pretty=true` is already set (user wants colored output).
592/// - `--version` / `-v` / `--init` are present (non-diagnostic invocations).
593/// - `--pretty=false` is already present (rewriter is a no-op; beholder still
594///   attaches in Parser mode so diagnostics are captured — argv unchanged, no
595///   `rewrite_added` entry on status).
596pub struct TscBeholderFactory;
597
598impl BeholderFactory for TscBeholderFactory {
599    fn name(&self) -> &'static str { "tsc" }
600    fn version(&self) -> &'static str { "3.0" }
601
602    fn matches(&self, argv: &[String]) -> bool {
603        if argv.first().map(String::as_str) != Some("tsc") {
604            return false;
605        }
606        // Decline for non-diagnostic invocations.
607        if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v" | "--init")) {
608            return false;
609        }
610        // Decline if the user explicitly requested colored (pretty) output.
611        // `--pretty` alone means true; `--pretty=true` is explicit. We do NOT
612        // decline for `--pretty=false` — the rewriter becomes a no-op but we
613        // still want to parse the output.
614        !argv.iter().any(|a| a == "--pretty" || a == "--pretty=true")
615    }
616
617    fn mode(&self) -> BeholderMode {
618        BeholderMode::Rewriter {
619            adjust_argv: |argv| {
620                // Only inject if the user hasn't already set --pretty=false.
621                if !argv.iter().any(|a| a == "--pretty=false") {
622                    argv.push("--pretty=false".to_owned());
623                }
624            },
625        }
626    }
627
628    fn create(&self) -> Box<dyn Beholder> {
629        Box::new(TscBeholder::default())
630    }
631
632    fn tool_version_range(&self) -> Option<ToolVersionRange> {
633        // --pretty=false has been available since tsc 1.x; 3.0.0 is a
634        // conservative lower bound for the stable output format.
635        Some(ToolVersionRange { min: Some("3.0.0"), max: None })
636    }
637}
638
639/// Per-run tsc parser.
640///
641/// Buffers incoming bytes and extracts complete lines. Diagnostic lines in the
642/// `--pretty=false` format are turned into structured events; watch-mode
643/// timestamp headers and blank lines are silently skipped. Summary lines
644/// ("Found N errors.") produce an Info event so the agent can see the final
645/// outcome without polling run status.
646pub struct TscBeholder {
647    buf: Vec<u8>,
648}
649
650impl Default for TscBeholder {
651    fn default() -> Self {
652        Self { buf: Vec::new() }
653    }
654}
655
656impl Beholder for TscBeholder {
657    fn name(&self) -> &'static str { "tsc" }
658    fn version(&self) -> &'static str { "3.0" }
659    fn mode(&self) -> BeholderMode {
660        BeholderMode::Rewriter {
661            adjust_argv: |argv| {
662                if !argv.iter().any(|a| a == "--pretty=false") {
663                    argv.push("--pretty=false".to_owned());
664                }
665            },
666        }
667    }
668
669    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
670        self.buf.extend_from_slice(&chunk.bytes);
671        let mut events = Vec::new();
672        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
673            let raw: Vec<u8> = self.buf.drain(..=nl).collect();
674            let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
675            if line.is_empty() { continue }
676            // Skip watch-mode timestamp headers: "[12:00:00 AM] …"
677            if line.starts_with('[') { continue }
678
679            let source = EventSource::Beholder {
680                name: "tsc".to_owned(),
681                version: "3.0".to_owned(),
682            };
683
684            if let Some(diag) = parse_tsc_diagnostic(line) {
685                let mut fields = serde_json::json!({});
686                fields["error"] = serde_json::json!({ "code": diag.code });
687                fields["file"] = serde_json::json!({
688                    "path": diag.file,
689                    "line": diag.line,
690                    "col":  diag.col,
691                });
692                events.push(Event {
693                    run_id: chunk.run_id.clone(),
694                    seq: 0,
695                    offset_ms: chunk.offset_ms,
696                    level: diag.level,
697                    target: "tsc".to_owned(),
698                    msg: diag.msg,
699                    fields,
700                    anchor: Some(ChunkRef { seq: chunk.seq }),
701                    source,
702                });
703            } else if let Some(summary) = parse_tsc_summary(line) {
704                events.push(Event {
705                    run_id: chunk.run_id.clone(),
706                    seq: 0,
707                    offset_ms: chunk.offset_ms,
708                    level: if summary.errors == 0 { Level::Info } else { Level::Error },
709                    target: "tsc".to_owned(),
710                    msg: line.to_owned(),
711                    fields: serde_json::json!({ "build": { "errors": summary.errors } }),
712                    anchor: Some(ChunkRef { seq: chunk.seq }),
713                    source,
714                });
715            }
716        }
717        events
718    }
719}
720
721// ─── tsc parsing helpers ──────────────────────────────────────────────────────
722
723struct TscDiagnostic {
724    file: String,
725    line: u32,
726    col: u32,
727    level: Level,
728    code: String,
729    msg: String,
730}
731
732struct TscSummary {
733    errors: u32,
734}
735
736/// Parse a single `--pretty=false` diagnostic line of the form:
737/// `<file>(<line>,<col>): <level> TS<code>: <message>`
738fn parse_tsc_diagnostic(line: &str) -> Option<TscDiagnostic> {
739    const MARKERS: &[(&str, Level)] = &[
740        ("): error TS", Level::Error),
741        ("): warning TS", Level::Warn),
742        ("): message TS", Level::Info),
743    ];
744
745    for (marker, level) in MARKERS {
746        let Some(marker_pos) = line.find(marker) else { continue };
747
748        // Everything before ")" is "file(line,col".
749        let file_pos_str = &line[..marker_pos];
750        let Some((file, ln, col)) = parse_file_pos(file_pos_str) else { continue };
751
752        // After "): error TS" (etc.) starts the numeric code.
753        let after = &line[marker_pos + marker.len()..];
754        let code_len = after.chars().take_while(|c| c.is_ascii_digit()).count();
755        if code_len == 0 { continue; }
756        let code = format!("TS{}", &after[..code_len]);
757
758        let rest = &after[code_len..];
759        if !rest.starts_with(": ") { continue; }
760        let msg = rest[2..].to_owned();
761
762        return Some(TscDiagnostic { file, line: ln, col, level: *level, code, msg });
763    }
764    None
765}
766
767/// Extract `(file, line, col)` from the `file(line,col` part of a tsc diagnostic.
768///
769/// `s` is the substring before the closing `)`, e.g. `"src/foo.ts(10,5"`.
770fn parse_file_pos(s: &str) -> Option<(String, u32, u32)> {
771    let comma = s.rfind(',')?;
772    let col: u32 = s[comma + 1..].parse().ok()?;
773
774    let before_comma = &s[..comma];
775    let open_paren = before_comma.rfind('(')?;
776    let ln: u32 = before_comma[open_paren + 1..].parse().ok()?;
777
778    let file = s[..open_paren].to_owned();
779    if file.is_empty() { return None; }
780
781    Some((file, ln, col))
782}
783
784/// Parse `"Found N errors."` / `"Found N errors in M files."` summary lines.
785fn parse_tsc_summary(line: &str) -> Option<TscSummary> {
786    let rest = line.strip_prefix("Found ")?;
787    // Extract the leading digit sequence (error count).
788    let count_len = rest.chars().take_while(|c| c.is_ascii_digit()).count();
789    if count_len == 0 { return None; }
790    let errors: u32 = rest[..count_len].parse().ok()?;
791    // Remainder must start with " error" to distinguish from other "Found …" lines.
792    if !rest[count_len..].starts_with(" error") { return None; }
793    Some(TscSummary { errors })
794}
795
796// ─── Cargo JSON parsing ───────────────────────────────────────────────────────
797
798#[derive(serde::Deserialize)]
799struct CargoLine {
800    reason: String,
801    message: Option<CargoMessage>,
802    success: Option<bool>,
803}
804
805#[derive(serde::Deserialize)]
806struct CargoMessage {
807    message: String,
808    level: String,
809    code: Option<CargoCode>,
810    spans: Vec<CargoSpan>,
811}
812
813#[derive(serde::Deserialize)]
814struct CargoCode {
815    code: String,
816}
817
818#[derive(serde::Deserialize)]
819struct CargoSpan {
820    file_name: String,
821    is_primary: bool,
822    line_start: u32,
823    column_start: u32,
824}
825
826/// Convert an already-parsed `CargoLine` into a structured `Event`, or `None`
827/// for reasons we intentionally skip (e.g. `compiler-artifact`).
828fn parse_cargo_line_inner(line: &CargoLine, chunk: &OutputChunk) -> Option<Event> {
829    let source = EventSource::Beholder {
830        name: "cargo".to_owned(),
831        version: "1.38".to_owned(),
832    };
833
834    match line.reason.as_str() {
835        "compiler-message" => {
836            let msg = line.message.as_ref()?;
837            let level = cargo_level(&msg.level);
838            let primary = msg.spans.iter().find(|s| s.is_primary);
839
840            let mut fields = serde_json::json!({});
841            if let Some(code) = &msg.code {
842                fields["error"] = serde_json::json!({ "code": code.code });
843            }
844            if let Some(span) = primary {
845                fields["file"] = serde_json::json!({
846                    "path": span.file_name,
847                    "line": span.line_start,
848                    "col":  span.column_start,
849                });
850            }
851
852            Some(Event {
853                run_id: chunk.run_id.clone(),
854                seq: 0, // assigned by store on insert
855                offset_ms: chunk.offset_ms,
856                level,
857                target: "cargo::rustc".to_owned(),
858                msg: msg.message.clone(),
859                fields,
860                anchor: Some(ChunkRef { seq: chunk.seq }),
861                source,
862            })
863        }
864        "build-finished" => {
865            let ok = line.success.unwrap_or(false);
866            Some(Event {
867                run_id: chunk.run_id.clone(),
868                seq: 0,
869                offset_ms: chunk.offset_ms,
870                level: if ok { Level::Info } else { Level::Error },
871                target: "cargo".to_owned(),
872                msg: if ok { "build finished".to_owned() } else { "build failed".to_owned() },
873                fields: serde_json::json!({ "build": { "success": ok } }),
874                anchor: Some(ChunkRef { seq: chunk.seq }),
875                source,
876            })
877        }
878        _ => None,
879    }
880}
881
882fn cargo_level(s: &str) -> Level {
883    match s {
884        "error" | "failure-note" => Level::Error,
885        "warning" => Level::Warn,
886        "note" | "help" => Level::Info,
887        _ => Level::Debug,
888    }
889}
890
891// ─── ESLint beholder ─────────────────────────────────────────────────────────
892
893/// Factory for the bundled ESLint beholder (Tier 1.5, Rewriter mode).
894///
895/// Matches `eslint` invocations and rewrites argv to add `--format=json`, which
896/// makes ESLint emit a single JSON array at exit rather than human-formatted
897/// text. Parsing happens in `on_done` because ESLint writes the entire document
898/// at process exit, not line-by-line.
899///
900/// Declines when:
901/// - `--version`, `--env-info`, or `--print-config` are present (non-lint).
902/// - `--format=<value>` is already set to something other than `json` (respects
903///   explicit user intent; if it's already `json`, the rewriter is a no-op and
904///   the beholder still attaches as a parser).
905pub struct EslintBeholderFactory;
906
907impl BeholderFactory for EslintBeholderFactory {
908    fn name(&self) -> &'static str { "eslint" }
909    fn version(&self) -> &'static str { "8.0" }
910
911    fn matches(&self, argv: &[String]) -> bool {
912        if argv.first().map(String::as_str) != Some("eslint") {
913            return false;
914        }
915        // Non-lint invocations.
916        if argv.iter().any(|a| matches!(a.as_str(), "--version" | "--env-info" | "--print-config")) {
917            return false;
918        }
919        // Decline if the user explicitly chose a non-json formatter.
920        // `--format=json` is fine — rewriter is a no-op; beholder still parses.
921        // `-f json` / `-f compact` etc. are also handled.
922        let format_arg = argv.windows(2)
923            .find(|w| w[0] == "-f" || w[0] == "--format")
924            .map(|w| w[1].as_str());
925        let format_eq = argv.iter()
926            .find(|a| a.starts_with("--format="))
927            .map(|a| a.trim_start_matches("--format="));
928        let explicit_format = format_arg.or(format_eq);
929        matches!(explicit_format, None | Some("json"))
930    }
931
932    fn mode(&self) -> BeholderMode {
933        BeholderMode::Rewriter {
934            adjust_argv: |argv| {
935                if !argv.iter().any(|a| a == "--format=json") {
936                    argv.push("--format=json".to_owned());
937                }
938            },
939        }
940    }
941
942    fn create(&self) -> Box<dyn Beholder> {
943        Box::new(EslintBeholder::default())
944    }
945
946    fn tool_version_range(&self) -> Option<ToolVersionRange> {
947        // --format=json has been available since ESLint 1.x; 8.0 is the
948        // ESLint version range this beholder was built and tested against.
949        Some(ToolVersionRange { min: Some("8.0.0"), max: None })
950    }
951}
952
953/// Per-run ESLint JSON parser. Buffers all output; parses in `on_done`.
954///
955/// ESLint writes the entire JSON array to stdout at process exit. There is no
956/// streaming line-by-line format in the `json` formatter, so incremental
957/// parsing is not possible. The beholder accumulates raw bytes and flushes
958/// structured events once the PTY closes.
959pub struct EslintBeholder {
960    buf: Vec<u8>,
961    unknown_format: Option<String>,
962}
963
964impl Default for EslintBeholder {
965    fn default() -> Self {
966        Self { buf: Vec::new(), unknown_format: None }
967    }
968}
969
970impl Beholder for EslintBeholder {
971    fn name(&self) -> &'static str { "eslint" }
972    fn version(&self) -> &'static str { "8.0" }
973    fn mode(&self) -> BeholderMode {
974        BeholderMode::Rewriter {
975            adjust_argv: |argv| {
976                if !argv.iter().any(|a| a == "--format=json") {
977                    argv.push("--format=json".to_owned());
978                }
979            },
980        }
981    }
982
983    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
984        self.buf.extend_from_slice(&chunk.bytes);
985        Vec::new()
986    }
987
988    fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
989        if self.buf.is_empty() {
990            return Vec::new();
991        }
992        let Ok(s) = std::str::from_utf8(&self.buf) else {
993            self.unknown_format = Some("non-UTF-8 output".to_owned());
994            return Vec::new();
995        };
996        // ESLint JSON output is an array; strip any leading/trailing ANSI escapes
997        // or shell prompt noise that a PTY might inject before/after the document.
998        let s = s.trim();
999        match serde_json::from_str::<Vec<EslintFile>>(s) {
1000            Ok(files) => eslint_to_events(files, run_id, offset_ms),
1001            Err(e) => {
1002                self.unknown_format = Some(format!("failed to parse ESLint JSON: {e}"));
1003                Vec::new()
1004            }
1005        }
1006    }
1007
1008    fn unknown_format_reason(&self) -> Option<&str> {
1009        self.unknown_format.as_deref()
1010    }
1011}
1012
1013// ─── ESLint JSON structs ──────────────────────────────────────────────────────
1014
1015#[derive(serde::Deserialize)]
1016struct EslintFile {
1017    #[serde(rename = "filePath")]
1018    file_path: String,
1019    messages: Vec<EslintMessage>,
1020}
1021
1022#[derive(serde::Deserialize)]
1023struct EslintMessage {
1024    #[serde(rename = "ruleId")]
1025    rule_id: Option<String>,
1026    severity: u8,
1027    message: String,
1028    line: Option<u32>,
1029    column: Option<u32>,
1030}
1031
1032fn eslint_to_events(
1033    files: Vec<EslintFile>,
1034    run_id: &crate::types::TaskRunId,
1035    offset_ms: u32,
1036) -> Vec<Event> {
1037    let mut events = Vec::new();
1038    for file in files {
1039        for msg in file.messages {
1040            let level = match msg.severity {
1041                2 => Level::Error,
1042                1 => Level::Warn,
1043                _ => Level::Info,
1044            };
1045            let mut fields = serde_json::json!({});
1046            if let Some(ref rule) = msg.rule_id {
1047                fields["error"] = serde_json::json!({ "code": rule });
1048            }
1049            let mut file_fields = serde_json::json!({ "path": file.file_path });
1050            if let Some(l) = msg.line { file_fields["line"] = serde_json::json!(l); }
1051            if let Some(c) = msg.column { file_fields["col"] = serde_json::json!(c); }
1052            fields["file"] = file_fields;
1053
1054            events.push(Event {
1055                run_id: run_id.clone(),
1056                seq: 0,
1057                offset_ms,
1058                level,
1059                target: "eslint".to_owned(),
1060                msg: msg.message,
1061                fields,
1062                anchor: None,
1063                source: EventSource::Beholder {
1064                    name: "eslint".to_owned(),
1065                    version: "8.0".to_owned(),
1066                },
1067            });
1068        }
1069    }
1070    events
1071}
1072
1073// ─── Biome beholder ──────────────────────────────────────────────────────────
1074
1075/// Subcommands that produce lint diagnostics.
1076const BIOME_LINT_SUBCOMMANDS: &[&str] = &["check", "lint", "ci"];
1077
1078/// Factory for the bundled Biome beholder (Tier 1.5, Rewriter mode).
1079///
1080/// Matches `biome check/lint/ci` invocations and rewrites argv to add
1081/// `--reporter=json`, which makes Biome emit a single JSON object at exit.
1082/// Parsing happens in `on_done`.
1083///
1084/// Declines when:
1085/// - Subcommand is not one of `check`, `lint`, `ci`.
1086/// - `--reporter=<value>` is already set to something other than `json`.
1087/// - `--version` is present.
1088pub struct BiomeBeholderFactory;
1089
1090impl BeholderFactory for BiomeBeholderFactory {
1091    fn name(&self) -> &'static str { "biome" }
1092    fn version(&self) -> &'static str { "1.0" }
1093
1094    fn matches(&self, argv: &[String]) -> bool {
1095        if argv.first().map(String::as_str) != Some("biome") {
1096            return false;
1097        }
1098        if argv.iter().any(|a| a == "--version") {
1099            return false;
1100        }
1101        // Must have a lint-producing subcommand.
1102        let has_lint_sub = argv.iter().skip(1)
1103            .any(|a| BIOME_LINT_SUBCOMMANDS.contains(&a.as_str()));
1104        if !has_lint_sub {
1105            return false;
1106        }
1107        // Decline if the user explicitly chose a non-json reporter.
1108        let reporter = argv.iter()
1109            .find(|a| a.starts_with("--reporter="))
1110            .map(|a| a.trim_start_matches("--reporter="));
1111        matches!(reporter, None | Some("json"))
1112    }
1113
1114    fn mode(&self) -> BeholderMode {
1115        BeholderMode::Rewriter {
1116            adjust_argv: |argv| {
1117                if !argv.iter().any(|a| a == "--reporter=json") {
1118                    argv.push("--reporter=json".to_owned());
1119                }
1120            },
1121        }
1122    }
1123
1124    fn create(&self) -> Box<dyn Beholder> {
1125        Box::new(BiomeBeholder::default())
1126    }
1127
1128    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1129        // --reporter=json was available from Biome 1.0.
1130        Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1131    }
1132}
1133
1134/// Per-run Biome JSON parser. Buffers all output; parses in `on_done`.
1135///
1136/// Biome writes a single JSON object to stdout at process exit. Like ESLint,
1137/// the entire document appears at once, so incremental parsing is not possible.
1138pub struct BiomeBeholder {
1139    buf: Vec<u8>,
1140    unknown_format: Option<String>,
1141}
1142
1143impl Default for BiomeBeholder {
1144    fn default() -> Self {
1145        Self { buf: Vec::new(), unknown_format: None }
1146    }
1147}
1148
1149impl Beholder for BiomeBeholder {
1150    fn name(&self) -> &'static str { "biome" }
1151    fn version(&self) -> &'static str { "1.0" }
1152    fn mode(&self) -> BeholderMode {
1153        BeholderMode::Rewriter {
1154            adjust_argv: |argv| {
1155                if !argv.iter().any(|a| a == "--reporter=json") {
1156                    argv.push("--reporter=json".to_owned());
1157                }
1158            },
1159        }
1160    }
1161
1162    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1163        self.buf.extend_from_slice(&chunk.bytes);
1164        Vec::new()
1165    }
1166
1167    fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1168        if self.buf.is_empty() {
1169            return Vec::new();
1170        }
1171        let Ok(s) = std::str::from_utf8(&self.buf) else {
1172            self.unknown_format = Some("non-UTF-8 output".to_owned());
1173            return Vec::new();
1174        };
1175        let s = s.trim();
1176        match serde_json::from_str::<BiomeOutput>(s) {
1177            Ok(output) => biome_to_events(output.diagnostics, run_id, offset_ms),
1178            Err(e) => {
1179                self.unknown_format = Some(format!("failed to parse Biome JSON: {e}"));
1180                Vec::new()
1181            }
1182        }
1183    }
1184
1185    fn unknown_format_reason(&self) -> Option<&str> {
1186        self.unknown_format.as_deref()
1187    }
1188}
1189
1190// ─── Biome JSON structs ───────────────────────────────────────────────────────
1191
1192#[derive(serde::Deserialize)]
1193struct BiomeOutput {
1194    #[serde(default)]
1195    diagnostics: Vec<BiomeDiagnostic>,
1196}
1197
1198#[derive(serde::Deserialize)]
1199struct BiomeDiagnostic {
1200    category: Option<String>,
1201    severity: String,
1202    description: String,
1203    location: Option<BiomeLocation>,
1204}
1205
1206#[derive(serde::Deserialize)]
1207struct BiomeLocation {
1208    path: Option<BiomePath>,
1209}
1210
1211#[derive(serde::Deserialize)]
1212struct BiomePath {
1213    file: Option<String>,
1214}
1215
1216fn biome_to_events(
1217    diagnostics: Vec<BiomeDiagnostic>,
1218    run_id: &crate::types::TaskRunId,
1219    offset_ms: u32,
1220) -> Vec<Event> {
1221    diagnostics.into_iter().map(|d| {
1222        let level = match d.severity.as_str() {
1223            "fatal" => Level::Fatal,
1224            "error" => Level::Error,
1225            "warning" => Level::Warn,
1226            "information" => Level::Info,
1227            "hint" => Level::Debug,
1228            _ => Level::Warn,
1229        };
1230        let mut fields = serde_json::json!({});
1231        if let Some(ref cat) = d.category {
1232            fields["error"] = serde_json::json!({ "code": cat });
1233        }
1234        if let Some(loc) = d.location {
1235            if let Some(path) = loc.path {
1236                if let Some(file) = path.file {
1237                    fields["file"] = serde_json::json!({ "path": file });
1238                }
1239            }
1240        }
1241        Event {
1242            run_id: run_id.clone(),
1243            seq: 0,
1244            offset_ms,
1245            level,
1246            target: "biome".to_owned(),
1247            msg: d.description,
1248            fields,
1249            anchor: None,
1250            source: EventSource::Beholder {
1251                name: "biome".to_owned(),
1252                version: "1.0".to_owned(),
1253            },
1254        }
1255    }).collect()
1256}
1257
1258// ─── JS test beholder (vitest / jest / bun-test) ─────────────────────────────
1259
1260/// Argv rewriter for vitest: adds `--reporter=json` when not already present.
1261fn vitest_adjust_argv(argv: &mut Vec<String>) {
1262    if !argv.iter().any(|a| a == "--reporter=json") {
1263        argv.push("--reporter=json".to_owned());
1264    }
1265}
1266
1267/// Argv rewriter for jest: adds `--json` when not already present.
1268fn jest_adjust_argv(argv: &mut Vec<String>) {
1269    if !argv.iter().any(|a| a == "--json") {
1270        argv.push("--json".to_owned());
1271    }
1272}
1273
1274/// Argv rewriter for bun test: adds `--reporter=json` when not already present.
1275fn bun_test_adjust_argv(argv: &mut Vec<String>) {
1276    if !argv.iter().any(|a| a == "--reporter=json") {
1277        argv.push("--reporter=json".to_owned());
1278    }
1279}
1280
1281/// Factory for the bundled vitest beholder (Tier 1.5, Rewriter mode).
1282///
1283/// Matches `vitest` invocations and rewrites argv to add `--reporter=json`,
1284/// which makes vitest emit a single Jest-compatible JSON document to stdout
1285/// at exit. Parsing happens in `on_done`.
1286///
1287/// Declines when:
1288/// - `--version` / `-v` are present (non-test invocations).
1289/// - `--reporter=<value>` is already set to a non-json reporter (respects
1290///   explicit user intent; if it's already `json`, rewriter is a no-op).
1291pub struct VitestBeholderFactory;
1292
1293impl BeholderFactory for VitestBeholderFactory {
1294    fn name(&self) -> &'static str { "vitest" }
1295    fn version(&self) -> &'static str { "1.0" }
1296
1297    fn matches(&self, argv: &[String]) -> bool {
1298        if argv.first().map(String::as_str) != Some("vitest") {
1299            return false;
1300        }
1301        if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v")) {
1302            return false;
1303        }
1304        // Decline if user set a non-json reporter (--reporter=<x> or --reporter <x>).
1305        let reporter_eq = argv.iter()
1306            .find(|a| a.starts_with("--reporter="))
1307            .map(|a| a.trim_start_matches("--reporter="));
1308        let reporter_space = argv.windows(2)
1309            .find(|w| w[0] == "--reporter")
1310            .map(|w| w[1].as_str());
1311        let explicit = reporter_eq.or(reporter_space);
1312        matches!(explicit, None | Some("json"))
1313    }
1314
1315    fn mode(&self) -> BeholderMode {
1316        BeholderMode::Rewriter { adjust_argv: vitest_adjust_argv }
1317    }
1318
1319    fn create(&self) -> Box<dyn Beholder> {
1320        Box::new(JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv))
1321    }
1322
1323    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1324        Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1325    }
1326}
1327
1328/// Factory for the bundled jest beholder (Tier 1.5, Rewriter mode).
1329///
1330/// Matches `jest` invocations and rewrites argv to add `--json`, which makes
1331/// jest emit a JSON test report to stdout at exit. Parsing happens in `on_done`.
1332///
1333/// Declines when:
1334/// - `--version` / `-v` are present.
1335/// - `--outputFile` is present (JSON would go to a file, not stdout).
1336pub struct JestBeholderFactory;
1337
1338impl BeholderFactory for JestBeholderFactory {
1339    fn name(&self) -> &'static str { "jest" }
1340    fn version(&self) -> &'static str { "27.0" }
1341
1342    fn matches(&self, argv: &[String]) -> bool {
1343        if argv.first().map(String::as_str) != Some("jest") {
1344            return false;
1345        }
1346        if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v")) {
1347            return false;
1348        }
1349        // --outputFile sends JSON to a file rather than stdout; we can't capture that.
1350        if argv.iter().any(|a| a.starts_with("--outputFile")) {
1351            return false;
1352        }
1353        true
1354    }
1355
1356    fn mode(&self) -> BeholderMode {
1357        BeholderMode::Rewriter { adjust_argv: jest_adjust_argv }
1358    }
1359
1360    fn create(&self) -> Box<dyn Beholder> {
1361        Box::new(JsTestBeholder::new("jest", "27.0", jest_adjust_argv))
1362    }
1363
1364    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1365        Some(ToolVersionRange { min: Some("27.0.0"), max: None })
1366    }
1367}
1368
1369/// Factory for the bundled bun-test beholder (Tier 1.5, Rewriter mode).
1370///
1371/// Matches `bun test` invocations (argv[0]="bun", argv[1]="test") and rewrites
1372/// argv to add `--reporter=json`. Parsing happens in `on_done`.
1373///
1374/// Declines when:
1375/// - `--version` is present.
1376/// - `--reporter=<value>` is already set to a non-json reporter.
1377pub struct BunTestBeholderFactory;
1378
1379impl BeholderFactory for BunTestBeholderFactory {
1380    fn name(&self) -> &'static str { "bun-test" }
1381    fn version(&self) -> &'static str { "1.0" }
1382
1383    fn matches(&self, argv: &[String]) -> bool {
1384        if argv.first().map(String::as_str) != Some("bun") {
1385            return false;
1386        }
1387        if argv.get(1).map(String::as_str) != Some("test") {
1388            return false;
1389        }
1390        if argv.iter().any(|a| a == "--version") {
1391            return false;
1392        }
1393        let reporter = argv.iter()
1394            .find(|a| a.starts_with("--reporter="))
1395            .map(|a| a.trim_start_matches("--reporter="));
1396        matches!(reporter, None | Some("json"))
1397    }
1398
1399    fn mode(&self) -> BeholderMode {
1400        BeholderMode::Rewriter { adjust_argv: bun_test_adjust_argv }
1401    }
1402
1403    fn create(&self) -> Box<dyn Beholder> {
1404        Box::new(JsTestBeholder::new("bun-test", "1.0", bun_test_adjust_argv))
1405    }
1406
1407    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1408        Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1409    }
1410}
1411
1412/// Per-run JS test JSON parser shared by vitest, jest, and bun-test.
1413///
1414/// All three produce a Jest-compatible JSON report on stdout at process exit
1415/// (via `--reporter=json` for vitest/bun-test, `--json` for jest). There is no
1416/// streaming line-by-line format, so incremental parsing is not possible: the
1417/// beholder accumulates raw bytes and flushes structured events in `on_done`.
1418///
1419/// Events emitted:
1420/// - One summary event (Info on success, Error on failure) with pass/fail counts.
1421/// - One Error event per failed assertion with the test name and failure message.
1422pub struct JsTestBeholder {
1423    tool: &'static str,
1424    version: &'static str,
1425    adjust_argv_fn: fn(&mut Vec<String>),
1426    buf: Vec<u8>,
1427    unknown_format: Option<String>,
1428}
1429
1430impl JsTestBeholder {
1431    fn new(tool: &'static str, version: &'static str, adjust_argv_fn: fn(&mut Vec<String>)) -> Self {
1432        Self { tool, version, adjust_argv_fn, buf: Vec::new(), unknown_format: None }
1433    }
1434}
1435
1436impl Beholder for JsTestBeholder {
1437    fn name(&self) -> &'static str { self.tool }
1438    fn version(&self) -> &'static str { self.version }
1439    fn mode(&self) -> BeholderMode {
1440        BeholderMode::Rewriter { adjust_argv: self.adjust_argv_fn }
1441    }
1442
1443    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1444        self.buf.extend_from_slice(&chunk.bytes);
1445        Vec::new()
1446    }
1447
1448    fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1449        if self.buf.is_empty() {
1450            return Vec::new();
1451        }
1452        let Ok(s) = std::str::from_utf8(&self.buf) else {
1453            self.unknown_format = Some("non-UTF-8 output".to_owned());
1454            return Vec::new();
1455        };
1456        let s = s.trim();
1457        match serde_json::from_str::<JsTestReport>(s) {
1458            Ok(report) => js_test_to_events(report, run_id, offset_ms, self.tool, self.version),
1459            Err(e) => {
1460                self.unknown_format = Some(format!("failed to parse {} JSON: {e}", self.tool));
1461                Vec::new()
1462            }
1463        }
1464    }
1465
1466    fn unknown_format_reason(&self) -> Option<&str> {
1467        self.unknown_format.as_deref()
1468    }
1469}
1470
1471// ─── JS test JSON structs ─────────────────────────────────────────────────────
1472
1473#[derive(serde::Deserialize)]
1474struct JsTestReport {
1475    #[serde(default)]
1476    success: bool,
1477    #[serde(rename = "numTotalTests", default)]
1478    num_total: u32,
1479    #[serde(rename = "numPassedTests", default)]
1480    num_passed: u32,
1481    #[serde(rename = "numFailedTests", default)]
1482    num_failed: u32,
1483    #[serde(rename = "numPendingTests", default)]
1484    num_pending: u32,
1485    #[serde(rename = "testResults", default)]
1486    test_results: Vec<JsTestSuiteResult>,
1487}
1488
1489#[derive(serde::Deserialize)]
1490struct JsTestSuiteResult {
1491    #[serde(rename = "testFilePath")]
1492    file_path: String,
1493    #[serde(rename = "assertionResults", default)]
1494    assertions: Vec<JsTestAssertionResult>,
1495}
1496
1497#[derive(serde::Deserialize)]
1498struct JsTestAssertionResult {
1499    #[serde(rename = "fullName", default)]
1500    full_name: String,
1501    status: String,
1502    #[serde(rename = "failureMessages", default)]
1503    failure_messages: Vec<String>,
1504}
1505
1506fn js_test_to_events(
1507    report: JsTestReport,
1508    run_id: &crate::types::TaskRunId,
1509    offset_ms: u32,
1510    tool: &'static str,
1511    version: &'static str,
1512) -> Vec<Event> {
1513    let mut events = Vec::new();
1514
1515    // Summary event: one per run.
1516    let summary_msg = if report.success {
1517        format!("{} passed", report.num_passed)
1518    } else {
1519        format!("{} failed, {} passed", report.num_failed, report.num_passed)
1520    };
1521    events.push(Event {
1522        run_id: run_id.clone(),
1523        seq: 0,
1524        offset_ms,
1525        level: if report.success { Level::Info } else { Level::Error },
1526        target: tool.to_owned(),
1527        msg: summary_msg,
1528        fields: serde_json::json!({
1529            "test": {
1530                "total": report.num_total,
1531                "passed": report.num_passed,
1532                "failed": report.num_failed,
1533                "pending": report.num_pending,
1534            },
1535            "build": { "success": report.success }
1536        }),
1537        anchor: None,
1538        source: EventSource::Beholder {
1539            name: tool.to_owned(),
1540            version: version.to_owned(),
1541        },
1542    });
1543
1544    // One Error event per failed assertion.
1545    for suite in &report.test_results {
1546        for assertion in &suite.assertions {
1547            if assertion.status != "failed" {
1548                continue;
1549            }
1550            // Use the first failure message (first line only to keep events compact).
1551            let msg = assertion.failure_messages
1552                .first()
1553                .and_then(|s| s.lines().next())
1554                .map(str::to_owned)
1555                .unwrap_or_else(|| assertion.full_name.clone());
1556            events.push(Event {
1557                run_id: run_id.clone(),
1558                seq: 0,
1559                offset_ms,
1560                level: Level::Error,
1561                target: format!("{}::test", tool),
1562                msg,
1563                fields: serde_json::json!({
1564                    "test": { "name": assertion.full_name },
1565                    "file": { "path": suite.file_path },
1566                }),
1567                anchor: None,
1568                source: EventSource::Beholder {
1569                    name: tool.to_owned(),
1570                    version: version.to_owned(),
1571                },
1572            });
1573        }
1574    }
1575
1576    events
1577}
1578
1579// ─── pytest beholder ─────────────────────────────────────────────────────────
1580
1581/// Factory for the bundled pytest beholder (Tier 1.5, Parser mode).
1582///
1583/// Matches `pytest`, `py.test`, and `python[-3] -m pytest` invocations and
1584/// parses the standard human-readable output for structured events. Parser mode
1585/// is used because pytest-json-report is not universally available; the standard
1586/// text format is stable across all supported pytest versions (≥ 7).
1587///
1588/// Events emitted:
1589/// - One Error event per `FAILED`/`ERROR` line in the short test summary.
1590/// - One summary event (Info on success, Error on failure) from the final
1591///   `N failed, M passed[…] in Ws` separator line.
1592///
1593/// Declines when:
1594/// - `--version` / `-V` / `--help` / `-h` are present.
1595/// - `--collect-only` / `--co` are present (no test execution, different output).
1596pub struct PytestBeholderFactory;
1597
1598impl BeholderFactory for PytestBeholderFactory {
1599    fn name(&self) -> &'static str { "pytest" }
1600    fn version(&self) -> &'static str { "7.0" }
1601
1602    fn matches(&self, argv: &[String]) -> bool {
1603        let first = argv.first().map(String::as_str);
1604        let is_direct = matches!(first, Some("pytest") | Some("py.test"));
1605        let is_python_m = matches!(first, Some("python") | Some("python3"))
1606            && argv.windows(2).any(|w| w[0] == "-m" && w[1] == "pytest");
1607        if !is_direct && !is_python_m {
1608            return false;
1609        }
1610        for a in argv {
1611            match a.as_str() {
1612                "--version" | "-V" | "--help" | "-h" => return false,
1613                "--collect-only" | "--co" => return false,
1614                _ => {}
1615            }
1616        }
1617        true
1618    }
1619
1620    fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1621
1622    fn create(&self) -> Box<dyn Beholder> { Box::new(PytestBeholder::default()) }
1623
1624    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1625        Some(ToolVersionRange { min: Some("7.0.0"), max: None })
1626    }
1627}
1628
1629/// Per-run pytest text-output parser.
1630///
1631/// Processes lines incrementally. Emits:
1632/// - Error events for `FAILED`/`ERROR` entries in the short test summary section.
1633/// - A summary event from the final `=== N failed, M passed … ===` line.
1634pub struct PytestBeholder {
1635    buf: Vec<u8>,
1636    /// The inner text of the last `=== … ===` summary separator seen.
1637    summary_line: Option<String>,
1638    unknown_format: Option<String>,
1639}
1640
1641impl Default for PytestBeholder {
1642    fn default() -> Self {
1643        Self { buf: Vec::new(), summary_line: None, unknown_format: None }
1644    }
1645}
1646
1647impl Beholder for PytestBeholder {
1648    fn name(&self) -> &'static str { "pytest" }
1649    fn version(&self) -> &'static str { "7.0" }
1650    fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1651
1652    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1653        self.buf.extend_from_slice(&chunk.bytes);
1654        let mut events = Vec::new();
1655
1656        let source = EventSource::Beholder {
1657            name: "pytest".to_owned(),
1658            version: "7.0".to_owned(),
1659        };
1660
1661        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
1662            let raw: Vec<u8> = self.buf.drain(..=nl).collect();
1663            let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
1664            if line.is_empty() { continue }
1665
1666            if let Some(ev) = parse_pytest_failure_line(line, chunk, &source) {
1667                events.push(ev);
1668                continue;
1669            }
1670
1671            // Track the final `=== N failed, M passed … ===` line; may be updated
1672            // multiple times in watch/loop mode — we keep the latest.
1673            if line.starts_with("==") && line.ends_with("==") {
1674                let inner = line.trim_matches('=').trim();
1675                if inner.contains(" passed") || inner.contains(" failed")
1676                    || inner.contains(" error")
1677                {
1678                    self.summary_line = Some(inner.to_owned());
1679                }
1680            }
1681        }
1682
1683        events
1684    }
1685
1686    fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1687        let Some(ref summary) = self.summary_line else {
1688            return Vec::new();
1689        };
1690        let Ok(parsed) = parse_pytest_summary(summary) else {
1691            self.unknown_format = Some(format!("could not parse pytest summary: {summary:?}"));
1692            return Vec::new();
1693        };
1694
1695        let level = if parsed.failed > 0 || parsed.errors > 0 { Level::Error } else { Level::Info };
1696        let msg = format_pytest_summary_msg(&parsed);
1697
1698        vec![Event {
1699            run_id: run_id.clone(),
1700            seq: 0,
1701            offset_ms,
1702            level,
1703            target: "pytest".to_owned(),
1704            msg,
1705            fields: serde_json::json!({
1706                "test": {
1707                    "total":   parsed.passed + parsed.failed + parsed.skipped + parsed.errors,
1708                    "passed":  parsed.passed,
1709                    "failed":  parsed.failed,
1710                    "skipped": parsed.skipped,
1711                    "errors":  parsed.errors,
1712                },
1713                "build": { "success": parsed.failed == 0 && parsed.errors == 0 }
1714            }),
1715            anchor: None,
1716            source: EventSource::Beholder {
1717                name: "pytest".to_owned(),
1718                version: "7.0".to_owned(),
1719            },
1720        }]
1721    }
1722
1723    fn unknown_format_reason(&self) -> Option<&str> { self.unknown_format.as_deref() }
1724}
1725
1726// ─── pytest parsing helpers ───────────────────────────────────────────────────
1727
1728/// Parse a `FAILED` or `ERROR` short-summary line into an Event, or return None.
1729///
1730/// Formats handled:
1731/// - `FAILED tests/foo.py::test_name - AssertionError: assert 1 == 2`
1732/// - `ERROR tests/broken.py - ImportError: No module named 'x'`
1733fn parse_pytest_failure_line(
1734    line: &str,
1735    chunk: &OutputChunk,
1736    source: &EventSource,
1737) -> Option<Event> {
1738    let (label, rest) = if let Some(r) = line.strip_prefix("FAILED ") {
1739        ("FAILED", r)
1740    } else if let Some(r) = line.strip_prefix("ERROR ") {
1741        ("ERROR", r)
1742    } else {
1743        return None;
1744    };
1745
1746    // Require a `.py` in the nodeid portion to avoid false positives on
1747    // arbitrary lines that happen to start with these words.
1748    let (nodeid, reason) = if let Some(idx) = rest.find(" - ") {
1749        let nodeid = &rest[..idx];
1750        if !nodeid.contains(".py") { return None; }
1751        (nodeid, rest[idx + 3..].trim())
1752    } else {
1753        if !rest.contains(".py") { return None; }
1754        (rest, "")
1755    };
1756
1757    let (file_path, test_name) = if let Some(idx) = nodeid.find("::") {
1758        (&nodeid[..idx], &nodeid[idx + 2..])
1759    } else {
1760        (nodeid, "")
1761    };
1762
1763    let msg = if reason.is_empty() {
1764        format!("{label} {nodeid}")
1765    } else {
1766        reason.to_owned()
1767    };
1768
1769    Some(Event {
1770        run_id: chunk.run_id.clone(),
1771        seq: 0,
1772        offset_ms: chunk.offset_ms,
1773        level: Level::Error,
1774        target: "pytest::test".to_owned(),
1775        msg,
1776        fields: serde_json::json!({
1777            "test": { "name": test_name },
1778            "file": { "path": file_path },
1779        }),
1780        anchor: Some(ChunkRef { seq: chunk.seq }),
1781        source: source.clone(),
1782    })
1783}
1784
1785struct PytestSummary {
1786    passed:  u32,
1787    failed:  u32,
1788    skipped: u32,
1789    errors:  u32,
1790}
1791
1792/// Parse `"2 failed, 5 passed, 1 skipped in 1.23s"` → `PytestSummary`.
1793fn parse_pytest_summary(s: &str) -> Result<PytestSummary, ()> {
1794    // Strip duration suffix " in W.XXs" (optional — may be absent on early exit).
1795    let s = if let Some(idx) = s.rfind(" in ") { &s[..idx] } else { s };
1796
1797    let mut passed  = 0u32;
1798    let mut failed  = 0u32;
1799    let mut skipped = 0u32;
1800    let mut errors  = 0u32;
1801    let mut any = false;
1802
1803    for part in s.split(", ") {
1804        let part = part.trim();
1805        let mut it = part.splitn(2, ' ');
1806        let count: u32 = it.next().and_then(|n| n.parse().ok()).ok_or(())?;
1807        let kind = it.next().ok_or(())?;
1808        any = true;
1809        if kind.starts_with("passed")  { passed  = count; }
1810        else if kind.starts_with("failed")  { failed  = count; }
1811        else if kind.starts_with("skipped") { skipped = count; }
1812        else if kind.starts_with("error")   { errors  = count; }
1813        // Other kinds (warnings, deselected, xfailed, xpassed) pass through.
1814    }
1815
1816    if !any { return Err(()); }
1817    Ok(PytestSummary { passed, failed, skipped, errors })
1818}
1819
1820fn format_pytest_summary_msg(s: &PytestSummary) -> String {
1821    let mut parts: Vec<String> = Vec::new();
1822    if s.failed  > 0 { parts.push(format!("{} failed", s.failed)); }
1823    if s.errors  > 0 { parts.push(format!("{} error{}", s.errors, if s.errors == 1 { "" } else { "s" })); }
1824    if s.passed  > 0 { parts.push(format!("{} passed", s.passed)); }
1825    if s.skipped > 0 { parts.push(format!("{} skipped", s.skipped)); }
1826    if parts.is_empty() { return "no tests ran".to_owned(); }
1827    parts.join(", ")
1828}
1829
1830// ─── vite-build beholder ─────────────────────────────────────────────────────
1831
1832/// Factory for the bundled vite-build beholder (Tier 1.5, Parser mode).
1833///
1834/// Matches `vite build` invocations (bare or via wrapper: `bunx vite build`,
1835/// `pnpm vite build`). Parses the human-formatted output for `(!)` warnings,
1836/// per-asset bundle sizes, and the final `built in X.XXs` timing line.
1837///
1838/// Declines when:
1839/// - `argv[0]` (post-wrapper-stripping) is not `vite`.
1840/// - No `build` subcommand is present in argv.
1841/// - `--help` / `-h` / `--version` flags are present.
1842pub struct ViteBuildBeholderFactory;
1843
1844impl BeholderFactory for ViteBuildBeholderFactory {
1845    fn name(&self) -> &'static str { "vite-build" }
1846    fn version(&self) -> &'static str { "4.0" }
1847
1848    fn matches(&self, argv: &[String]) -> bool {
1849        if argv.first().map(String::as_str) != Some("vite") { return false; }
1850        if !argv.iter().skip(1).any(|a| a == "build") { return false; }
1851        for a in argv {
1852            match a.as_str() {
1853                "--help" | "-h" | "--version" => return false,
1854                _ => {}
1855            }
1856        }
1857        true
1858    }
1859
1860    fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1861
1862    fn create(&self) -> Box<dyn Beholder> { Box::new(ViteBuildBeholder::default()) }
1863
1864    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1865        Some(ToolVersionRange { min: Some("4.0.0"), max: None })
1866    }
1867}
1868
1869/// Per-run vite build output parser.
1870///
1871/// Processes lines incrementally. Emits:
1872/// - `Warn` events for `(!) <text>` warning lines (oversized chunks, eval, etc.)
1873/// - `Info` events for `dist/<file>  X kB  │ gzip: Y kB` bundle-output lines.
1874/// - An `Info` timing event from the `✓ built in X.XXs` line.
1875pub struct ViteBuildBeholder {
1876    buf: Vec<u8>,
1877    unknown_format: Option<String>,
1878}
1879
1880impl Default for ViteBuildBeholder {
1881    fn default() -> Self {
1882        Self { buf: Vec::new(), unknown_format: None }
1883    }
1884}
1885
1886impl Beholder for ViteBuildBeholder {
1887    fn name(&self) -> &'static str { "vite-build" }
1888    fn version(&self) -> &'static str { "4.0" }
1889    fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1890
1891    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1892        self.buf.extend_from_slice(&chunk.bytes);
1893        let mut events = Vec::new();
1894
1895        let source = EventSource::Beholder {
1896            name: "vite-build".to_owned(),
1897            version: "4.0".to_owned(),
1898        };
1899
1900        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
1901            let raw: Vec<u8> = self.buf.drain(..=nl).collect();
1902            let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
1903            if line.is_empty() { continue }
1904
1905            // (!) warning lines
1906            if let Some(warn_text) = line.strip_prefix("(!) ") {
1907                events.push(Event {
1908                    run_id: chunk.run_id.clone(),
1909                    seq: 0,
1910                    offset_ms: chunk.offset_ms,
1911                    level: Level::Warn,
1912                    target: "vite-build::warning".to_owned(),
1913                    msg: warn_text.trim().to_owned(),
1914                    fields: serde_json::json!({ "build": { "warning": true } }),
1915                    anchor: Some(ChunkRef { seq: chunk.seq }),
1916                    source: source.clone(),
1917                });
1918                continue;
1919            }
1920
1921            // dist/<file>  X.XX kB  [│ gzip:  Y.YY kB]
1922            if let Some(ev) = parse_vite_bundle_line(line, chunk, &source) {
1923                events.push(ev);
1924                continue;
1925            }
1926
1927            // "✓ built in X.XXs" or "built in X.XXs"
1928            if let Some(ms) = parse_vite_built_ms(line) {
1929                events.push(Event {
1930                    run_id: chunk.run_id.clone(),
1931                    seq: 0,
1932                    offset_ms: chunk.offset_ms,
1933                    level: Level::Info,
1934                    target: "vite-build".to_owned(),
1935                    msg: format!("built in {:.2}s", ms as f64 / 1000.0),
1936                    fields: serde_json::json!({
1937                        "build": { "duration_ms": ms, "success": true }
1938                    }),
1939                    anchor: Some(ChunkRef { seq: chunk.seq }),
1940                    source: source.clone(),
1941                });
1942            }
1943        }
1944
1945        events
1946    }
1947
1948    fn unknown_format_reason(&self) -> Option<&str> { self.unknown_format.as_deref() }
1949}
1950
1951// ─── vite-build parsing helpers ───────────────────────────────────────────────
1952
1953/// Parse `dist/<file>  X.XX kB [│ gzip:  Y.YY kB]` into an Event, or None.
1954///
1955/// The `│` (U+2502) separator and gzip column are optional.
1956fn parse_vite_bundle_line(line: &str, chunk: &OutputChunk, source: &EventSource) -> Option<Event> {
1957    let trimmed = line.trim();
1958    if !trimmed.starts_with("dist/") { return None; }
1959    if !trimmed.contains("kB") { return None; }
1960
1961    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
1962    let file = tokens.first()?;
1963
1964    // Collect all `float kB` pairs in order: first is size, second (if any) is gzip.
1965    let mut values: Vec<f64> = Vec::new();
1966    let mut i = 1;
1967    while i < tokens.len() {
1968        if let Ok(v) = tokens[i].parse::<f64>() {
1969            if tokens.get(i + 1).copied() == Some("kB") {
1970                values.push(v);
1971                i += 2;
1972                continue;
1973            }
1974        }
1975        i += 1;
1976    }
1977    let size_kb = *values.first()?;
1978    let gzip_kb = values.get(1).copied();
1979
1980    let msg = if let Some(g) = gzip_kb {
1981        format!("{file}: {size_kb:.2} kB (gzip: {g:.2} kB)")
1982    } else {
1983        format!("{file}: {size_kb:.2} kB")
1984    };
1985
1986    let mut fields = serde_json::json!({
1987        "file": { "path": file },
1988        "build": { "size_kb": size_kb },
1989    });
1990    if let Some(g) = gzip_kb {
1991        fields["build"]["gzip_kb"] = serde_json::json!(g);
1992    }
1993
1994    Some(Event {
1995        run_id: chunk.run_id.clone(),
1996        seq: 0,
1997        offset_ms: chunk.offset_ms,
1998        level: Level::Info,
1999        target: "vite-build::bundle".to_owned(),
2000        msg,
2001        fields,
2002        anchor: Some(ChunkRef { seq: chunk.seq }),
2003        source: source.clone(),
2004    })
2005}
2006
2007/// Parse `"✓ built in 2.43s"` or `"built in 2.43s"` → duration in ms.
2008fn parse_vite_built_ms(line: &str) -> Option<u64> {
2009    let idx = line.find("built in ")?;
2010    let rest = line[idx + "built in ".len()..].trim();
2011    let secs_str = rest.trim_end_matches(|c: char| c.is_alphabetic());
2012    let secs: f64 = secs_str.parse().ok()?;
2013    Some((secs * 1000.0).round() as u64)
2014}
2015
2016
2017// ─── Tests ────────────────────────────────────────────────────────────────────
2018
2019#[cfg(test)]
2020mod tests {
2021    use super::*;
2022    // ─── Test fixtures ────────────────────────────────────────────────────────
2023
2024    /// A no-op parser beholder that matches commands starting with a prefix.
2025    struct PrefixFactory {
2026        prefix: &'static str,
2027        name: &'static str,
2028    }
2029
2030    struct NoopBeholder {
2031        name: &'static str,
2032    }
2033
2034    impl BeholderFactory for PrefixFactory {
2035        fn name(&self) -> &'static str { self.name }
2036        fn version(&self) -> &'static str { "1.0" }
2037        fn matches(&self, argv: &[String]) -> bool {
2038            argv.first().map(|s| s.starts_with(self.prefix)).unwrap_or(false)
2039        }
2040        fn mode(&self) -> BeholderMode { BeholderMode::Parser }
2041        fn create(&self) -> Box<dyn Beholder> { Box::new(NoopBeholder { name: self.name }) }
2042    }
2043
2044    impl Beholder for NoopBeholder {
2045        fn name(&self) -> &'static str { self.name }
2046        fn version(&self) -> &'static str { "1.0" }
2047        fn mode(&self) -> BeholderMode { BeholderMode::Parser }
2048        fn parse_chunk(&mut self, _chunk: &OutputChunk) -> Vec<Event> { vec![] }
2049    }
2050
2051    /// A rewriter beholder that declines when --no-json is present.
2052    struct RewriterFactory;
2053    struct RewriterBeholder;
2054
2055    impl BeholderFactory for RewriterFactory {
2056        fn name(&self) -> &'static str { "cargo" }
2057        fn version(&self) -> &'static str { "1.78" }
2058        fn matches(&self, argv: &[String]) -> bool {
2059            argv.first().map(|s| s == "cargo").unwrap_or(false)
2060                && !argv.iter().any(|a| a == "--no-json")
2061        }
2062        fn mode(&self) -> BeholderMode {
2063            BeholderMode::Rewriter {
2064                adjust_argv: |argv| {
2065                    argv.push("--message-format=json-render-diagnostics".to_string());
2066                },
2067            }
2068        }
2069        fn create(&self) -> Box<dyn Beholder> { Box::new(RewriterBeholder) }
2070    }
2071
2072    impl Beholder for RewriterBeholder {
2073        fn name(&self) -> &'static str { "cargo" }
2074        fn version(&self) -> &'static str { "1.78" }
2075        fn mode(&self) -> BeholderMode {
2076            BeholderMode::Rewriter {
2077                adjust_argv: |argv| {
2078                    argv.push("--message-format=json-render-diagnostics".to_string());
2079                },
2080            }
2081        }
2082        fn parse_chunk(&mut self, _chunk: &OutputChunk) -> Vec<Event> { vec![] }
2083    }
2084
2085    // ─── Registry tests ───────────────────────────────────────────────────────
2086
2087    #[test]
2088    fn empty_registry_returns_none_auto() {
2089        let registry = BeholderRegistry::new();
2090        let result = registry.attach("cargo check", &BeholderSelect::Auto, false);
2091        assert!(result.beholder.is_none());
2092        assert_eq!(result.status.text, "none:auto");
2093        assert_eq!(result.argv, vec!["cargo", "check"]);
2094    }
2095
2096    #[test]
2097    fn auto_attaches_first_matching() {
2098        let mut registry = BeholderRegistry::new();
2099        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2100        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2101        assert!(result.beholder.is_some());
2102        assert_eq!(result.status.text, "attached:cargo@1.0");
2103    }
2104
2105    #[test]
2106    fn rewriter_adjusts_argv_in_result() {
2107        let mut registry = BeholderRegistry::new();
2108        registry.register(Box::new(RewriterFactory));
2109        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2110        assert!(result.beholder.is_some());
2111        // Rewrite must be surfaced in status text and in rewrite_added.
2112        assert!(
2113            result.status.text.starts_with("attached:cargo@1.78"),
2114            "got: {}",
2115            result.status.text
2116        );
2117        assert!(
2118            result.status.text.contains("rewrite="),
2119            "rewrite must be surfaced in status; got: {}",
2120            result.status.text
2121        );
2122        assert!(
2123            result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2124            "rewriter must append the JSON flag"
2125        );
2126        assert_eq!(
2127            result.status.rewrite_added.as_deref(),
2128            Some(vec!["--message-format=json-render-diagnostics".to_string()].as_slice()),
2129            "rewrite_added must list the injected arg"
2130        );
2131    }
2132
2133    #[test]
2134    fn auto_respects_decline_from_matches() {
2135        let mut registry = BeholderRegistry::new();
2136        registry.register(Box::new(RewriterFactory));
2137        let result = registry.attach("cargo check --no-json", &BeholderSelect::Auto, false);
2138        assert!(result.beholder.is_none());
2139        assert_eq!(result.status.text, "none:auto");
2140        // argv unchanged — no rewrite when beholder declined
2141        assert!(!result.argv.contains(&"--message-format=json-render-diagnostics".to_string()));
2142    }
2143
2144    /// R739-S2. The live failure this guard exists for: `yah build run`
2145    /// relocates an agent's whole bash line into `task.run`, and 76.5% of the
2146    /// lines in this camp are compound. Without the guard the rewriter's flag
2147    /// lands on the last token of the pipeline and the command dies with
2148    /// `tail: unrecognized option '--message-format=…'`.
2149    #[test]
2150    fn a_piped_line_gets_no_rewriter_because_the_flag_would_land_on_the_last_stage() {
2151        let mut registry = BeholderRegistry::new();
2152        registry.register(Box::new(RewriterFactory));
2153        let result = registry.attach(
2154            "cargo check -p yah-agent-tools --lib 2>&1 | tail -12",
2155            &BeholderSelect::Auto,
2156            false,
2157        );
2158        assert!(result.beholder.is_none(), "no beholder may attach to a shell line");
2159        assert_eq!(result.status.text, "declined:auto reason=\"compound-command\"");
2160        assert!(
2161            !result
2162                .argv
2163                .contains(&"--message-format=json-render-diagnostics".to_string()),
2164            "the flag must not be spliced into `tail`'s arguments"
2165        );
2166    }
2167
2168    /// Force is not an escape hatch here: forcing a rewriter onto a compound
2169    /// line breaks the command just as thoroughly as Auto would.
2170    #[test]
2171    fn force_does_not_override_the_compound_command_guard() {
2172        let mut registry = BeholderRegistry::new();
2173        registry.register(Box::new(RewriterFactory));
2174        let result = registry.attach(
2175            "cargo check && cargo test",
2176            &BeholderSelect::Force("cargo".to_string()),
2177            false,
2178        );
2179        assert!(result.beholder.is_none());
2180        assert_eq!(result.status.text, "declined:auto reason=\"compound-command\"");
2181    }
2182
2183    #[test]
2184    fn a_bare_command_still_attaches() {
2185        let mut registry = BeholderRegistry::new();
2186        registry.register(Box::new(RewriterFactory));
2187        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2188        assert!(result.beholder.is_some(), "the guard must not cost the simple case");
2189    }
2190
2191    /// Over-declining is the deliberate error direction — see
2192    /// [`is_single_simple_command`]. Recorded so a later tightening is a
2193    /// choice rather than an accident.
2194    #[test]
2195    fn the_guard_is_a_character_scan_and_over_declines_on_purpose() {
2196        assert!(is_single_simple_command("cargo check --workspace"));
2197        assert!(is_single_simple_command("cargo test -p yah --lib mcp::tools"));
2198        // Genuinely compound.
2199        assert!(!is_single_simple_command("cargo check 2>&1 | tail -5"));
2200        assert!(!is_single_simple_command("cargo check && cargo test"));
2201        assert!(!is_single_simple_command("cargo check; echo done"));
2202        assert!(!is_single_simple_command("cargo check $(cat args)"));
2203        assert!(!is_single_simple_command("cargo check\ncargo test"));
2204        // Harmless in principle, declined anyway: no shell parser here.
2205        assert!(!is_single_simple_command("cargo check > out.txt"));
2206        assert!(!is_single_simple_command("cargo test -p yah -- --nocapture $FILTER"));
2207    }
2208
2209    #[test]
2210    fn explicit_none_bypasses_registry() {
2211        let mut registry = BeholderRegistry::new();
2212        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2213        let result = registry.attach("cargo check", &BeholderSelect::None, false);
2214        assert!(result.beholder.is_none());
2215        assert_eq!(result.status.text, "none:explicit");
2216    }
2217
2218    #[test]
2219    fn force_pins_by_name() {
2220        let mut registry = BeholderRegistry::new();
2221        registry.register(Box::new(PrefixFactory { prefix: "tsc", name: "tsc" }));
2222        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2223        // Force "cargo" even though Auto would have matched "tsc" with "tsc" prefix
2224        let result = registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), false);
2225        assert!(result.beholder.is_some());
2226        assert!(result.status.text.starts_with("forced:cargo"), "got: {}", result.status.text);
2227    }
2228
2229    #[test]
2230    fn force_against_flags_when_would_decline() {
2231        let mut registry = BeholderRegistry::new();
2232        registry.register(Box::new(RewriterFactory));
2233        // cargo with --no-json would normally decline; Force overrides
2234        let result =
2235            registry.attach("cargo check --no-json", &BeholderSelect::Force("cargo".to_string()), false);
2236        assert!(result.beholder.is_some());
2237        assert!(
2238            result.status.text.contains("forced-against-flags"),
2239            "got: {}",
2240            result.status.text
2241        );
2242    }
2243
2244    #[test]
2245    fn force_unknown_name_returns_none() {
2246        let registry = BeholderRegistry::new();
2247        let result =
2248            registry.attach("cargo check", &BeholderSelect::Force("unknown".to_string()), false);
2249        assert!(result.beholder.is_none());
2250    }
2251
2252    #[test]
2253    fn priority_order_first_match_wins() {
2254        let mut registry = BeholderRegistry::new();
2255        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "first" }));
2256        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "second" }));
2257        let result = registry.attach("cargo check", &BeholderSelect::Auto, false);
2258        assert_eq!(result.status.text, "attached:first@1.0");
2259    }
2260
2261    // ─── TTY-aware behavior tests ─────────────────────────────────────────────
2262
2263    #[test]
2264    fn tty_attached_causes_rewriter_to_decline_in_auto() {
2265        let mut registry = BeholderRegistry::new();
2266        registry.register(Box::new(RewriterFactory));
2267        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, true);
2268        // Beholder declines to preserve human output on the TTY.
2269        assert!(result.beholder.is_none(), "rewriter must not attach when tty_attached");
2270        assert!(
2271            result.status.text.contains("declined:cargo"),
2272            "got: {}",
2273            result.status.text
2274        );
2275        assert!(
2276            result.status.text.contains("tty-attached"),
2277            "got: {}",
2278            result.status.text
2279        );
2280        // argv must be unchanged — no rewrite applied.
2281        assert!(!result.argv.contains(&"--message-format=json-render-diagnostics".to_string()));
2282    }
2283
2284    #[test]
2285    fn tty_attached_does_not_affect_parser_beholder() {
2286        let mut registry = BeholderRegistry::new();
2287        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2288        // Parser beholders are fine on a TTY — they don't rewrite argv.
2289        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, true);
2290        assert!(result.beholder.is_some(), "parser beholder must attach even on a TTY");
2291        assert_eq!(result.status.text, "attached:cargo@1.0");
2292    }
2293
2294    #[test]
2295    fn force_overrides_tty_decline_and_records_forced_against_tty() {
2296        let mut registry = BeholderRegistry::new();
2297        registry.register(Box::new(RewriterFactory));
2298        // Force overrides the TTY-decline rule; operator explicitly wants structured output.
2299        let result =
2300            registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), true);
2301        assert!(result.beholder.is_some(), "Force must attach even on a TTY");
2302        assert!(
2303            result.status.text.contains("forced-against-tty"),
2304            "got: {}",
2305            result.status.text
2306        );
2307        // Rewrite still applied (forced) and surfaced.
2308        assert!(
2309            result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2310            "rewrite must still be applied when forced on TTY"
2311        );
2312    }
2313
2314    #[test]
2315    fn rewrite_surfaced_on_force_normal() {
2316        let mut registry = BeholderRegistry::new();
2317        registry.register(Box::new(RewriterFactory));
2318        let result =
2319            registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), false);
2320        assert!(result.beholder.is_some());
2321        assert!(
2322            result.status.text.contains("forced:cargo"),
2323            "got: {}",
2324            result.status.text
2325        );
2326        assert!(
2327            result.status.text.contains("rewrite="),
2328            "rewrite must be surfaced on forced attach; got: {}",
2329            result.status.text
2330        );
2331        assert!(result.status.rewrite_added.is_some());
2332    }
2333
2334    // ─── argv resolution tests ────────────────────────────────────────────────
2335
2336    #[test]
2337    fn resolve_bare_command() {
2338        assert_eq!(resolve_argv("cargo check --workspace"), ["cargo", "check", "--workspace"]);
2339    }
2340
2341    #[test]
2342    fn resolve_strips_bunx() {
2343        let argv = resolve_argv("bunx vitest --run");
2344        assert_eq!(argv[0], "vitest");
2345        assert_eq!(argv[1], "--run");
2346    }
2347
2348    #[test]
2349    fn resolve_strips_npx() {
2350        let argv = resolve_argv("npx tsc --noEmit");
2351        assert_eq!(argv[0], "tsc");
2352        assert_eq!(argv[1], "--noEmit");
2353    }
2354
2355    #[test]
2356    fn resolve_strips_pnpm() {
2357        let argv = resolve_argv("pnpm tsc");
2358        assert_eq!(argv[0], "tsc");
2359    }
2360
2361    #[test]
2362    fn resolve_strips_npm_exec() {
2363        let argv = resolve_argv("npm exec tsc --noEmit");
2364        assert_eq!(argv[0], "tsc");
2365        assert_eq!(argv[1], "--noEmit");
2366    }
2367
2368    #[test]
2369    fn resolve_pnpm_without_exec_subcommand() {
2370        // `pnpm tsc` — no exec subcommand, just strip pnpm
2371        let argv = resolve_argv("pnpm vitest --reporter=verbose");
2372        assert_eq!(argv[0], "vitest");
2373    }
2374
2375    #[test]
2376    fn resolve_empty_command() {
2377        let argv = resolve_argv("");
2378        assert!(argv.is_empty());
2379    }
2380
2381    // ─── CargoBeholderFactory tests ───────────────────────────────────────────
2382
2383    fn dummy_chunk(bytes: &[u8]) -> OutputChunk {
2384        use crate::types::{Stream, TaskRunId};
2385        OutputChunk {
2386            run_id: TaskRunId::new(),
2387            seq: 0,
2388            offset_ms: 0,
2389            stream: Stream::Stdout,
2390            bytes: bytes.to_vec(),
2391        }
2392    }
2393
2394    #[test]
2395    fn cargo_factory_matches_check() {
2396        let f = CargoBeholderFactory;
2397        let argv: Vec<String> = vec!["cargo".into(), "check".into(), "--workspace".into()];
2398        assert!(f.matches(&argv));
2399    }
2400
2401    #[test]
2402    fn cargo_factory_matches_clippy() {
2403        let f = CargoBeholderFactory;
2404        let argv: Vec<String> = vec!["cargo".into(), "clippy".into()];
2405        assert!(f.matches(&argv));
2406    }
2407
2408    #[test]
2409    fn cargo_factory_declines_explicit_message_format() {
2410        let f = CargoBeholderFactory;
2411        let argv: Vec<String> =
2412            vec!["cargo".into(), "check".into(), "--message-format=human".into()];
2413        assert!(!f.matches(&argv));
2414    }
2415
2416    #[test]
2417    fn cargo_factory_declines_non_diag_subcommand() {
2418        let f = CargoBeholderFactory;
2419        let argv: Vec<String> = vec!["cargo".into(), "fmt".into()];
2420        assert!(!f.matches(&argv));
2421    }
2422
2423    #[test]
2424    fn cargo_factory_declines_non_cargo() {
2425        let f = CargoBeholderFactory;
2426        let argv: Vec<String> = vec!["rustc".into(), "--edition=2021".into()];
2427        assert!(!f.matches(&argv));
2428    }
2429
2430    #[test]
2431    fn cargo_factory_rewriter_adds_flag() {
2432        let registry = default_registry();
2433        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2434        assert!(result.beholder.is_some(), "cargo beholder must attach");
2435        assert!(
2436            result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2437            "rewriter must inject the JSON flag; got: {:?}",
2438            result.argv
2439        );
2440        // Rewrite must be visible on status.
2441        assert!(
2442            result.status.text.contains("rewrite="),
2443            "rewrite must be surfaced in status; got: {}",
2444            result.status.text
2445        );
2446    }
2447
2448    #[test]
2449    fn cargo_beholder_parses_compiler_error() {
2450        let json = r#"{"reason":"compiler-message","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"message":{"$message_type":"diagnostic","message":"mismatched types","code":{"code":"E0308","explanation":""},"level":"error","spans":[{"file_name":"src/lib.rs","byte_start":0,"byte_end":1,"line_start":5,"line_end":5,"column_start":1,"column_end":14,"is_primary":true,"text":[],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"error[E0308]: mismatched types\n"}}"#;
2451        let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2452        let mut b = CargoBeholder::default();
2453        let events = b.parse_chunk(&chunk);
2454        assert_eq!(events.len(), 1);
2455        let ev = &events[0];
2456        assert_eq!(ev.target, "cargo::rustc");
2457        assert_eq!(ev.level, Level::Error);
2458        assert_eq!(ev.msg, "mismatched types");
2459        assert_eq!(ev.fields["error"]["code"], "E0308");
2460        assert_eq!(ev.fields["file"]["path"], "src/lib.rs");
2461        assert_eq!(ev.fields["file"]["line"], 5u32);
2462    }
2463
2464    #[test]
2465    fn cargo_beholder_parses_warning() {
2466        let json = r#"{"reason":"compiler-message","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"message":{"$message_type":"diagnostic","message":"unused variable: `x`","code":{"code":"unused_variables","explanation":""},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":0,"byte_end":1,"line_start":10,"line_end":10,"column_start":9,"column_end":10,"is_primary":true,"text":[],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"warning: unused variable"}}"#;
2467        let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2468        let mut b = CargoBeholder::default();
2469        let events = b.parse_chunk(&chunk);
2470        assert_eq!(events.len(), 1);
2471        assert_eq!(events[0].level, Level::Warn);
2472        assert_eq!(events[0].fields["file"]["line"], 10u32);
2473    }
2474
2475    #[test]
2476    fn cargo_beholder_parses_build_finished_success() {
2477        let json = r#"{"reason":"build-finished","success":true}"#;
2478        let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2479        let mut b = CargoBeholder::default();
2480        let events = b.parse_chunk(&chunk);
2481        assert_eq!(events.len(), 1);
2482        assert_eq!(events[0].level, Level::Info);
2483        assert_eq!(events[0].msg, "build finished");
2484    }
2485
2486    #[test]
2487    fn cargo_beholder_parses_build_finished_failure() {
2488        let json = r#"{"reason":"build-finished","success":false}"#;
2489        let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2490        let mut b = CargoBeholder::default();
2491        let events = b.parse_chunk(&chunk);
2492        assert_eq!(events.len(), 1);
2493        assert_eq!(events[0].level, Level::Error);
2494        assert_eq!(events[0].msg, "build failed");
2495    }
2496
2497    #[test]
2498    fn cargo_beholder_skips_artifacts_and_unknown() {
2499        // compiler-artifact and build-script-executed should produce no events
2500        let artifact = r#"{"reason":"compiler-artifact","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"profile":{"opt_level":"0","debuginfo":2,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":[],"executable":null,"fresh":false}"#;
2501        let script = r#"{"reason":"build-script-executed","package_id":"foo 0.1.0","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"/tmp"}"#;
2502        let mut b = CargoBeholder::default();
2503        let chunk = dummy_chunk(format!("{artifact}\n{script}\n").as_bytes());
2504        let events = b.parse_chunk(&chunk);
2505        assert!(events.is_empty(), "got: {events:?}");
2506    }
2507
2508    #[test]
2509    fn cargo_beholder_buffers_partial_lines() {
2510        let json = r#"{"reason":"build-finished","success":true}"#;
2511        let half = json.len() / 2;
2512        let mut b = CargoBeholder::default();
2513        // First half — no newline yet, no events
2514        let chunk1 = dummy_chunk(json[..half].as_bytes());
2515        let ev1 = b.parse_chunk(&chunk1);
2516        assert!(ev1.is_empty(), "should buffer partial line");
2517        // Second half + newline — now the event appears
2518        let chunk2 = dummy_chunk(format!("{}\n", &json[half..]).as_bytes());
2519        let ev2 = b.parse_chunk(&chunk2);
2520        assert_eq!(ev2.len(), 1);
2521        assert_eq!(ev2[0].msg, "build finished");
2522    }
2523
2524    // ─── Schema versioning + unknown-format fallback tests ────────────────────
2525
2526    #[test]
2527    fn cargo_beholder_unknown_format_reason_none_initially() {
2528        let b = CargoBeholder::default();
2529        assert!(b.unknown_format_reason().is_none(), "fresh beholder must not flag unknown format");
2530    }
2531
2532    #[test]
2533    fn cargo_factory_declares_tool_version_range() {
2534        let f = CargoBeholderFactory;
2535        let range = f.tool_version_range().expect("cargo factory must declare a version range");
2536        assert_eq!(range.min, Some("1.38.0"), "min version must be 1.38.0");
2537        assert!(range.max.is_none(), "no upper bound declared");
2538    }
2539
2540    #[test]
2541    fn cargo_beholder_format_probe_fires_after_limit() {
2542        // Feed FORMAT_PROBE_LIMIT JSON-object lines that lack a `reason` field.
2543        let bad_line = r#"{"not_reason":"something","value":42}"#;
2544        let input: String = (0..super::FORMAT_PROBE_LIMIT)
2545            .map(|_| format!("{bad_line}\n"))
2546            .collect();
2547        let chunk = dummy_chunk(input.as_bytes());
2548        let mut b = CargoBeholder::default();
2549        let events = b.parse_chunk(&chunk);
2550        assert!(events.is_empty(), "no events from unrecognized lines");
2551        let reason = b.unknown_format_reason();
2552        assert!(
2553            reason.is_some(),
2554            "unknown_format_reason must be Some after {FORMAT_PROBE_LIMIT} unrecognized JSON lines"
2555        );
2556        assert!(
2557            reason.unwrap().contains("no recognizable cargo JSON"),
2558            "reason text must describe the problem; got: {:?}",
2559            reason
2560        );
2561    }
2562
2563    #[test]
2564    fn cargo_beholder_format_probe_not_triggered_below_limit() {
2565        // One fewer than the limit — not yet flagged.
2566        let bad_line = r#"{"not_reason":"something"}"#;
2567        let input: String = (0..super::FORMAT_PROBE_LIMIT - 1)
2568            .map(|_| format!("{bad_line}\n"))
2569            .collect();
2570        let chunk = dummy_chunk(input.as_bytes());
2571        let mut b = CargoBeholder::default();
2572        b.parse_chunk(&chunk);
2573        assert!(
2574            b.unknown_format_reason().is_none(),
2575            "must not flag unknown format before probe limit"
2576        );
2577    }
2578
2579    #[test]
2580    fn cargo_beholder_format_probe_suppressed_after_recognized_line() {
2581        // One valid cargo line clears the probe; subsequent bad lines are silently dropped.
2582        let good = r#"{"reason":"build-finished","success":true}"#;
2583        let bad_line = r#"{"not_reason":"something"}"#;
2584        // Build more bad lines than the threshold.
2585        let mut input = format!("{good}\n");
2586        for _ in 0..super::FORMAT_PROBE_LIMIT + 2 {
2587            input.push_str(bad_line);
2588            input.push('\n');
2589        }
2590        let chunk = dummy_chunk(input.as_bytes());
2591        let mut b = CargoBeholder::default();
2592        let events = b.parse_chunk(&chunk);
2593        // The good line produces one event.
2594        assert_eq!(events.len(), 1, "expected one event from the recognized line");
2595        assert_eq!(events[0].msg, "build finished");
2596        assert!(
2597            b.unknown_format_reason().is_none(),
2598            "format probe must not fire after a recognized line has been seen"
2599        );
2600    }
2601
2602    #[test]
2603    fn cargo_beholder_stops_emitting_after_unknown_format() {
2604        // Trigger unknown format, then verify subsequent chunks produce no events.
2605        let bad_line = r#"{"not_reason":"x"}"#;
2606        let trigger: String = (0..super::FORMAT_PROBE_LIMIT)
2607            .map(|_| format!("{bad_line}\n"))
2608            .collect();
2609        let mut b = CargoBeholder::default();
2610        b.parse_chunk(&dummy_chunk(trigger.as_bytes()));
2611        assert!(b.unknown_format_reason().is_some(), "format must be flagged");
2612
2613        // Now feed a valid cargo line — must produce no events (beholder silenced).
2614        let valid = r#"{"reason":"build-finished","success":true}"#.to_string() + "\n";
2615        let events = b.parse_chunk(&dummy_chunk(valid.as_bytes()));
2616        assert!(events.is_empty(), "beholder must not emit events after unknown_format is set");
2617    }
2618
2619    #[test]
2620    fn cargo_beholder_non_json_lines_dont_count_toward_probe() {
2621        // Non-JSON lines (and empty lines) are skipped; they must not consume probe budget.
2622        let mut input = String::new();
2623        // More non-JSON lines than FORMAT_PROBE_LIMIT.
2624        for _ in 0..super::FORMAT_PROBE_LIMIT + 3 {
2625            input.push_str("  warning: some human text\n");
2626        }
2627        // Then a valid cargo line.
2628        input.push_str(r#"{"reason":"build-finished","success":true}"#);
2629        input.push('\n');
2630        let mut b = CargoBeholder::default();
2631        let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2632        assert_eq!(events.len(), 1, "valid cargo line must still produce an event");
2633        assert!(b.unknown_format_reason().is_none(), "non-JSON lines must not count toward probe");
2634    }
2635
2636    // ─── TscBeholderFactory tests ─────────────────────────────────────────────
2637
2638    #[test]
2639    fn tsc_factory_matches_bare_tsc() {
2640        let f = TscBeholderFactory;
2641        let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into()];
2642        assert!(f.matches(&argv));
2643    }
2644
2645    #[test]
2646    fn tsc_factory_matches_tsc_with_project() {
2647        let f = TscBeholderFactory;
2648        let argv: Vec<String> = vec!["tsc".into(), "-p".into(), "tsconfig.json".into()];
2649        assert!(f.matches(&argv));
2650    }
2651
2652    #[test]
2653    fn tsc_factory_matches_when_pretty_false_already_set() {
2654        // Should still attach (rewriter becomes a no-op; we still parse).
2655        let f = TscBeholderFactory;
2656        let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into(), "--pretty=false".into()];
2657        assert!(f.matches(&argv));
2658    }
2659
2660    #[test]
2661    fn tsc_factory_declines_pretty_true() {
2662        let f = TscBeholderFactory;
2663        let argv: Vec<String> = vec!["tsc".into(), "--pretty=true".into()];
2664        assert!(!f.matches(&argv));
2665    }
2666
2667    #[test]
2668    fn tsc_factory_declines_bare_pretty() {
2669        let f = TscBeholderFactory;
2670        let argv: Vec<String> = vec!["tsc".into(), "--pretty".into()];
2671        assert!(!f.matches(&argv));
2672    }
2673
2674    #[test]
2675    fn tsc_factory_declines_version() {
2676        let f = TscBeholderFactory;
2677        let argv: Vec<String> = vec!["tsc".into(), "--version".into()];
2678        assert!(!f.matches(&argv));
2679    }
2680
2681    #[test]
2682    fn tsc_factory_declines_init() {
2683        let f = TscBeholderFactory;
2684        let argv: Vec<String> = vec!["tsc".into(), "--init".into()];
2685        assert!(!f.matches(&argv));
2686    }
2687
2688    #[test]
2689    fn tsc_factory_declines_non_tsc_command() {
2690        let f = TscBeholderFactory;
2691        let argv: Vec<String> = vec!["node".into(), "build.js".into()];
2692        assert!(!f.matches(&argv));
2693    }
2694
2695    #[test]
2696    fn tsc_factory_rewriter_adds_pretty_false() {
2697        let registry = default_registry();
2698        let result = registry.attach("tsc --noEmit", &BeholderSelect::Auto, false);
2699        assert!(result.beholder.is_some(), "tsc beholder must attach");
2700        assert!(
2701            result.argv.contains(&"--pretty=false".to_string()),
2702            "rewriter must inject --pretty=false; got: {:?}",
2703            result.argv
2704        );
2705        assert!(
2706            result.status.text.contains("rewrite="),
2707            "rewrite must be surfaced in status; got: {}",
2708            result.status.text
2709        );
2710    }
2711
2712    #[test]
2713    fn tsc_factory_no_rewrite_when_pretty_false_present() {
2714        let registry = default_registry();
2715        let result = registry.attach("tsc --noEmit --pretty=false", &BeholderSelect::Auto, false);
2716        assert!(result.beholder.is_some(), "tsc beholder must attach");
2717        // Argv already had --pretty=false; rewriter is a no-op, no duplicate added.
2718        let count = result.argv.iter().filter(|a| a.as_str() == "--pretty=false").count();
2719        assert_eq!(count, 1, "--pretty=false must appear exactly once; got: {:?}", result.argv);
2720        // No rewrite_added because the flag was already present.
2721        assert!(
2722            result.status.rewrite_added.as_ref().map(|v| v.is_empty()).unwrap_or(true),
2723            "rewrite_added must be empty when flag already present"
2724        );
2725    }
2726
2727    #[test]
2728    fn tsc_beholder_parses_error_line() {
2729        let line = "src/foo.ts(10,5): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.\n";
2730        let mut b = TscBeholder::default();
2731        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2732        assert_eq!(events.len(), 1);
2733        let ev = &events[0];
2734        assert_eq!(ev.target, "tsc");
2735        assert_eq!(ev.level, Level::Error);
2736        assert_eq!(ev.msg, "Argument of type 'string' is not assignable to parameter of type 'number'.");
2737        assert_eq!(ev.fields["error"]["code"], "TS2345");
2738        assert_eq!(ev.fields["file"]["path"], "src/foo.ts");
2739        assert_eq!(ev.fields["file"]["line"], 10u32);
2740        assert_eq!(ev.fields["file"]["col"], 5u32);
2741    }
2742
2743    #[test]
2744    fn tsc_beholder_parses_warning_line() {
2745        let line = "src/bar.tsx(42,3): warning TS6133: 'x' is declared but its value is never read.\n";
2746        let mut b = TscBeholder::default();
2747        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2748        assert_eq!(events.len(), 1);
2749        assert_eq!(events[0].level, Level::Warn);
2750        assert_eq!(events[0].fields["error"]["code"], "TS6133");
2751        assert_eq!(events[0].fields["file"]["path"], "src/bar.tsx");
2752        assert_eq!(events[0].fields["file"]["line"], 42u32);
2753        assert_eq!(events[0].fields["file"]["col"], 3u32);
2754    }
2755
2756    #[test]
2757    fn tsc_beholder_parses_summary_with_errors() {
2758        let line = "Found 2 errors.\n";
2759        let mut b = TscBeholder::default();
2760        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2761        assert_eq!(events.len(), 1);
2762        assert_eq!(events[0].level, Level::Error);
2763        assert_eq!(events[0].fields["build"]["errors"], 2u32);
2764    }
2765
2766    #[test]
2767    fn tsc_beholder_parses_summary_zero_errors() {
2768        let line = "Found 0 errors.\n";
2769        let mut b = TscBeholder::default();
2770        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2771        assert_eq!(events.len(), 1);
2772        assert_eq!(events[0].level, Level::Info);
2773        assert_eq!(events[0].fields["build"]["errors"], 0u32);
2774    }
2775
2776    #[test]
2777    fn tsc_beholder_parses_summary_in_n_files() {
2778        let line = "Found 3 errors in 2 files.\n";
2779        let mut b = TscBeholder::default();
2780        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2781        assert_eq!(events.len(), 1);
2782        assert_eq!(events[0].fields["build"]["errors"], 3u32);
2783    }
2784
2785    #[test]
2786    fn tsc_beholder_skips_watch_mode_header() {
2787        let line = "[12:00:00 AM] Starting compilation in watch mode...\n";
2788        let mut b = TscBeholder::default();
2789        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2790        assert!(events.is_empty(), "watch-mode header must be skipped");
2791    }
2792
2793    #[test]
2794    fn tsc_beholder_skips_blank_lines() {
2795        let input = "\n\n";
2796        let mut b = TscBeholder::default();
2797        let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2798        assert!(events.is_empty());
2799    }
2800
2801    #[test]
2802    fn tsc_beholder_buffers_partial_lines() {
2803        let line = "src/foo.ts(1,1): error TS2304: Cannot find name 'foo'.";
2804        let half = line.len() / 2;
2805        let mut b = TscBeholder::default();
2806        let ev1 = b.parse_chunk(&dummy_chunk(line[..half].as_bytes()));
2807        assert!(ev1.is_empty(), "partial line must not produce events");
2808        let ev2 = b.parse_chunk(&dummy_chunk(format!("{}\n", &line[half..]).as_bytes()));
2809        assert_eq!(ev2.len(), 1, "complete line must produce one event");
2810        assert_eq!(ev2[0].fields["error"]["code"], "TS2304");
2811    }
2812
2813    #[test]
2814    fn tsc_beholder_multiple_errors_in_one_chunk() {
2815        let input = concat!(
2816            "src/a.ts(1,1): error TS2304: Cannot find name 'a'.\n",
2817            "src/b.ts(2,3): error TS2304: Cannot find name 'b'.\n",
2818            "Found 2 errors.\n",
2819        );
2820        let mut b = TscBeholder::default();
2821        let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2822        assert_eq!(events.len(), 3, "two diagnostics + one summary");
2823        assert_eq!(events[0].fields["file"]["path"], "src/a.ts");
2824        assert_eq!(events[1].fields["file"]["path"], "src/b.ts");
2825        assert_eq!(events[2].fields["build"]["errors"], 2u32);
2826    }
2827
2828    #[test]
2829    fn tsc_beholder_full_watch_cycle() {
2830        let input = concat!(
2831            "[12:00:00 AM] Starting compilation in watch mode...\n",
2832            "\n",
2833            "src/foo.ts(5,9): error TS2345: wrong type.\n",
2834            "\n",
2835            "[12:00:01 AM] Found 1 error. Watching for file changes.\n",
2836        );
2837        let mut b = TscBeholder::default();
2838        let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2839        // Only the diagnostic line produces an event; watch headers are skipped.
2840        assert_eq!(events.len(), 1, "only diagnostic line should produce event; got: {events:?}");
2841        assert_eq!(events[0].level, Level::Error);
2842        assert_eq!(events[0].fields["file"]["path"], "src/foo.ts");
2843    }
2844
2845    #[test]
2846    fn tsc_factory_declares_tool_version_range() {
2847        let f = TscBeholderFactory;
2848        let range = f.tool_version_range().expect("tsc factory must declare a version range");
2849        assert_eq!(range.min, Some("3.0.0"));
2850        assert!(range.max.is_none());
2851    }
2852
2853    // ─── EslintBeholderFactory tests ─────────────────────────────────────────
2854
2855    #[test]
2856    fn eslint_factory_matches_bare_eslint() {
2857        let f = EslintBeholderFactory;
2858        let argv: Vec<String> = vec!["eslint".into(), "src/".into()];
2859        assert!(f.matches(&argv));
2860    }
2861
2862    #[test]
2863    fn eslint_factory_matches_when_format_json_already_set() {
2864        let f = EslintBeholderFactory;
2865        let argv: Vec<String> = vec!["eslint".into(), "--format=json".into(), "src/".into()];
2866        assert!(f.matches(&argv), "must match when --format=json already present");
2867    }
2868
2869    #[test]
2870    fn eslint_factory_declines_non_json_format() {
2871        let f = EslintBeholderFactory;
2872        let argv: Vec<String> = vec!["eslint".into(), "--format=compact".into(), "src/".into()];
2873        assert!(!f.matches(&argv));
2874    }
2875
2876    #[test]
2877    fn eslint_factory_declines_version() {
2878        let f = EslintBeholderFactory;
2879        let argv: Vec<String> = vec!["eslint".into(), "--version".into()];
2880        assert!(!f.matches(&argv));
2881    }
2882
2883    #[test]
2884    fn eslint_factory_declines_env_info() {
2885        let f = EslintBeholderFactory;
2886        let argv: Vec<String> = vec!["eslint".into(), "--env-info".into()];
2887        assert!(!f.matches(&argv));
2888    }
2889
2890    #[test]
2891    fn eslint_factory_declines_non_eslint_command() {
2892        let f = EslintBeholderFactory;
2893        let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into()];
2894        assert!(!f.matches(&argv));
2895    }
2896
2897    #[test]
2898    fn eslint_factory_rewriter_adds_format_json() {
2899        let registry = default_registry();
2900        let result = registry.attach("eslint src/", &BeholderSelect::Auto, false);
2901        assert!(result.beholder.is_some(), "eslint beholder must attach");
2902        assert!(
2903            result.argv.contains(&"--format=json".to_string()),
2904            "rewriter must inject --format=json; got: {:?}",
2905            result.argv
2906        );
2907        assert!(
2908            result.status.text.contains("rewrite="),
2909            "rewrite must be surfaced in status; got: {}",
2910            result.status.text
2911        );
2912    }
2913
2914    #[test]
2915    fn eslint_factory_no_rewrite_when_format_json_present() {
2916        let registry = default_registry();
2917        let result = registry.attach("eslint --format=json src/", &BeholderSelect::Auto, false);
2918        assert!(result.beholder.is_some(), "eslint beholder must attach");
2919        let count = result.argv.iter().filter(|a| a.as_str() == "--format=json").count();
2920        assert_eq!(count, 1, "--format=json must appear exactly once; got: {:?}", result.argv);
2921    }
2922
2923    #[test]
2924    fn eslint_beholder_parse_chunk_buffers_only() {
2925        let json = r#"[{"filePath":"/src/a.js","messages":[]}]"#;
2926        let mut b = EslintBeholder::default();
2927        let events = b.parse_chunk(&dummy_chunk(json.as_bytes()));
2928        assert!(events.is_empty(), "parse_chunk must not emit events; ESLint parses at EOF");
2929    }
2930
2931    #[test]
2932    fn eslint_beholder_on_done_parses_errors_and_warnings() {
2933        use crate::types::TaskRunId;
2934        let json = r#"[
2935            {
2936                "filePath": "/src/foo.ts",
2937                "messages": [
2938                    {
2939                        "ruleId": "no-unused-vars",
2940                        "severity": 2,
2941                        "message": "'x' is defined but never used.",
2942                        "line": 10,
2943                        "column": 5
2944                    },
2945                    {
2946                        "ruleId": "no-console",
2947                        "severity": 1,
2948                        "message": "Unexpected console statement.",
2949                        "line": 20,
2950                        "column": 1
2951                    }
2952                ],
2953                "errorCount": 1,
2954                "warningCount": 1
2955            }
2956        ]"#;
2957        let mut b = EslintBeholder::default();
2958        b.parse_chunk(&dummy_chunk(json.as_bytes()));
2959        let run_id = TaskRunId::new();
2960        let events = b.on_done(&run_id, 0);
2961        assert_eq!(events.len(), 2);
2962        let err = &events[0];
2963        assert_eq!(err.level, Level::Error);
2964        assert_eq!(err.target, "eslint");
2965        assert_eq!(err.msg, "'x' is defined but never used.");
2966        assert_eq!(err.fields["error"]["code"], "no-unused-vars");
2967        assert_eq!(err.fields["file"]["path"], "/src/foo.ts");
2968        assert_eq!(err.fields["file"]["line"], 10u32);
2969        assert_eq!(err.fields["file"]["col"], 5u32);
2970        let warn = &events[1];
2971        assert_eq!(warn.level, Level::Warn);
2972        assert_eq!(warn.fields["error"]["code"], "no-console");
2973    }
2974
2975    #[test]
2976    fn eslint_beholder_on_done_empty_output() {
2977        use crate::types::TaskRunId;
2978        let mut b = EslintBeholder::default();
2979        let events = b.on_done(&TaskRunId::new(), 0);
2980        assert!(events.is_empty(), "empty buffer must produce no events");
2981    }
2982
2983    #[test]
2984    fn eslint_beholder_on_done_invalid_json_flags_unknown_format() {
2985        use crate::types::TaskRunId;
2986        let bad = b"not json at all";
2987        let mut b = EslintBeholder::default();
2988        b.parse_chunk(&dummy_chunk(bad));
2989        let events = b.on_done(&TaskRunId::new(), 0);
2990        assert!(events.is_empty(), "bad JSON must not produce events");
2991        assert!(
2992            b.unknown_format_reason().is_some(),
2993            "bad JSON must set unknown_format_reason"
2994        );
2995    }
2996
2997    #[test]
2998    fn eslint_beholder_on_done_chunks_split_across_multiple_calls() {
2999        use crate::types::TaskRunId;
3000        let json = r#"[{"filePath":"/a.ts","messages":[{"ruleId":"eqeqeq","severity":2,"message":"Use ===.","line":3,"column":7}],"errorCount":1,"warningCount":0}]"#;
3001        let mid = json.len() / 2;
3002        let mut b = EslintBeholder::default();
3003        b.parse_chunk(&dummy_chunk(json[..mid].as_bytes()));
3004        b.parse_chunk(&dummy_chunk(json[mid..].as_bytes()));
3005        let events = b.on_done(&TaskRunId::new(), 0);
3006        assert_eq!(events.len(), 1, "split-chunk delivery must still parse correctly");
3007        assert_eq!(events[0].fields["error"]["code"], "eqeqeq");
3008    }
3009
3010    #[test]
3011    fn eslint_factory_declares_tool_version_range() {
3012        let f = EslintBeholderFactory;
3013        let range = f.tool_version_range().expect("eslint factory must declare a version range");
3014        assert_eq!(range.min, Some("8.0.0"));
3015        assert!(range.max.is_none());
3016    }
3017
3018    // ─── BiomeBeholderFactory tests ───────────────────────────────────────────
3019
3020    #[test]
3021    fn biome_factory_matches_check() {
3022        let f = BiomeBeholderFactory;
3023        let argv: Vec<String> = vec!["biome".into(), "check".into(), "src/".into()];
3024        assert!(f.matches(&argv));
3025    }
3026
3027    #[test]
3028    fn biome_factory_matches_lint() {
3029        let f = BiomeBeholderFactory;
3030        let argv: Vec<String> = vec!["biome".into(), "lint".into(), "src/".into()];
3031        assert!(f.matches(&argv));
3032    }
3033
3034    #[test]
3035    fn biome_factory_matches_ci() {
3036        let f = BiomeBeholderFactory;
3037        let argv: Vec<String> = vec!["biome".into(), "ci".into(), "src/".into()];
3038        assert!(f.matches(&argv));
3039    }
3040
3041    #[test]
3042    fn biome_factory_declines_format_subcommand() {
3043        let f = BiomeBeholderFactory;
3044        let argv: Vec<String> = vec!["biome".into(), "format".into(), "src/".into()];
3045        assert!(!f.matches(&argv));
3046    }
3047
3048    #[test]
3049    fn biome_factory_declines_version() {
3050        let f = BiomeBeholderFactory;
3051        let argv: Vec<String> = vec!["biome".into(), "--version".into()];
3052        assert!(!f.matches(&argv));
3053    }
3054
3055    #[test]
3056    fn biome_factory_declines_non_json_reporter() {
3057        let f = BiomeBeholderFactory;
3058        let argv: Vec<String> = vec!["biome".into(), "check".into(), "--reporter=github".into()];
3059        assert!(!f.matches(&argv));
3060    }
3061
3062    #[test]
3063    fn biome_factory_matches_when_reporter_json_present() {
3064        let f = BiomeBeholderFactory;
3065        let argv: Vec<String> = vec!["biome".into(), "check".into(), "--reporter=json".into()];
3066        assert!(f.matches(&argv), "must match when --reporter=json already set");
3067    }
3068
3069    #[test]
3070    fn biome_factory_declines_non_biome_command() {
3071        let f = BiomeBeholderFactory;
3072        let argv: Vec<String> = vec!["eslint".into(), "src/".into()];
3073        assert!(!f.matches(&argv));
3074    }
3075
3076    #[test]
3077    fn biome_factory_rewriter_adds_reporter_json() {
3078        let registry = default_registry();
3079        let result = registry.attach("biome check src/", &BeholderSelect::Auto, false);
3080        assert!(result.beholder.is_some(), "biome beholder must attach");
3081        assert!(
3082            result.argv.contains(&"--reporter=json".to_string()),
3083            "rewriter must inject --reporter=json; got: {:?}",
3084            result.argv
3085        );
3086        assert!(
3087            result.status.text.contains("rewrite="),
3088            "rewrite must be surfaced in status; got: {}",
3089            result.status.text
3090        );
3091    }
3092
3093    #[test]
3094    fn biome_factory_no_rewrite_when_reporter_json_present() {
3095        let registry = default_registry();
3096        let result = registry.attach("biome lint --reporter=json src/", &BeholderSelect::Auto, false);
3097        assert!(result.beholder.is_some(), "biome beholder must attach");
3098        let count = result.argv.iter().filter(|a| a.as_str() == "--reporter=json").count();
3099        assert_eq!(count, 1, "--reporter=json must appear exactly once; got: {:?}", result.argv);
3100    }
3101
3102    #[test]
3103    fn biome_beholder_parse_chunk_buffers_only() {
3104        let json = r#"{"diagnostics":[],"summary":{}}"#;
3105        let mut b = BiomeBeholder::default();
3106        let events = b.parse_chunk(&dummy_chunk(json.as_bytes()));
3107        assert!(events.is_empty(), "parse_chunk must not emit events; Biome parses at EOF");
3108    }
3109
3110    #[test]
3111    fn biome_beholder_on_done_parses_error() {
3112        use crate::types::TaskRunId;
3113        let json = r#"{
3114            "diagnostics": [
3115                {
3116                    "category": "lint/suspicious/noDoubleEquals",
3117                    "severity": "error",
3118                    "description": "Use === instead of ==",
3119                    "location": {
3120                        "path": {"file": "src/foo.ts"}
3121                    }
3122                }
3123            ],
3124            "summary": {"changed": 0, "unchanged": 1, "errors": 1}
3125        }"#;
3126        let mut b = BiomeBeholder::default();
3127        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3128        let events = b.on_done(&TaskRunId::new(), 0);
3129        assert_eq!(events.len(), 1);
3130        let ev = &events[0];
3131        assert_eq!(ev.level, Level::Error);
3132        assert_eq!(ev.target, "biome");
3133        assert_eq!(ev.msg, "Use === instead of ==");
3134        assert_eq!(ev.fields["error"]["code"], "lint/suspicious/noDoubleEquals");
3135        assert_eq!(ev.fields["file"]["path"], "src/foo.ts");
3136    }
3137
3138    #[test]
3139    fn biome_beholder_on_done_parses_warning() {
3140        use crate::types::TaskRunId;
3141        let json = r#"{
3142            "diagnostics": [
3143                {
3144                    "category": "lint/style/useConst",
3145                    "severity": "warning",
3146                    "description": "Prefer const over let.",
3147                    "location": {"path": {"file": "src/bar.ts"}}
3148                }
3149            ],
3150            "summary": {}
3151        }"#;
3152        let mut b = BiomeBeholder::default();
3153        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3154        let events = b.on_done(&TaskRunId::new(), 0);
3155        assert_eq!(events.len(), 1);
3156        assert_eq!(events[0].level, Level::Warn);
3157        assert_eq!(events[0].fields["file"]["path"], "src/bar.ts");
3158    }
3159
3160    #[test]
3161    fn biome_beholder_on_done_empty_diagnostics() {
3162        use crate::types::TaskRunId;
3163        let json = r#"{"diagnostics":[],"summary":{"changed":0,"unchanged":5,"errors":0}}"#;
3164        let mut b = BiomeBeholder::default();
3165        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3166        let events = b.on_done(&TaskRunId::new(), 0);
3167        assert!(events.is_empty(), "zero diagnostics must produce no events");
3168    }
3169
3170    #[test]
3171    fn biome_beholder_on_done_invalid_json_flags_unknown_format() {
3172        use crate::types::TaskRunId;
3173        let mut b = BiomeBeholder::default();
3174        b.parse_chunk(&dummy_chunk(b"not json"));
3175        let events = b.on_done(&TaskRunId::new(), 0);
3176        assert!(events.is_empty());
3177        assert!(b.unknown_format_reason().is_some());
3178    }
3179
3180    #[test]
3181    fn biome_factory_declares_tool_version_range() {
3182        let f = BiomeBeholderFactory;
3183        let range = f.tool_version_range().expect("biome factory must declare a version range");
3184        assert_eq!(range.min, Some("1.0.0"));
3185        assert!(range.max.is_none());
3186    }
3187
3188    // ─── VitestBeholderFactory tests ──────────────────────────────────────────
3189
3190    #[test]
3191    fn vitest_factory_matches_bare_vitest() {
3192        let f = VitestBeholderFactory;
3193        let argv: Vec<String> = vec!["vitest".into(), "--run".into()];
3194        assert!(f.matches(&argv));
3195    }
3196
3197    #[test]
3198    fn vitest_factory_matches_with_reporter_json_already_set() {
3199        let f = VitestBeholderFactory;
3200        let argv: Vec<String> = vec!["vitest".into(), "--reporter=json".into()];
3201        assert!(f.matches(&argv), "must match when --reporter=json already present");
3202    }
3203
3204    #[test]
3205    fn vitest_factory_declines_non_json_reporter_eq() {
3206        let f = VitestBeholderFactory;
3207        let argv: Vec<String> = vec!["vitest".into(), "--reporter=verbose".into()];
3208        assert!(!f.matches(&argv));
3209    }
3210
3211    #[test]
3212    fn vitest_factory_declines_non_json_reporter_space() {
3213        let f = VitestBeholderFactory;
3214        let argv: Vec<String> = vec!["vitest".into(), "--reporter".into(), "verbose".into()];
3215        assert!(!f.matches(&argv));
3216    }
3217
3218    #[test]
3219    fn vitest_factory_declines_version() {
3220        let f = VitestBeholderFactory;
3221        let argv: Vec<String> = vec!["vitest".into(), "--version".into()];
3222        assert!(!f.matches(&argv));
3223    }
3224
3225    #[test]
3226    fn vitest_factory_declines_non_vitest_command() {
3227        let f = VitestBeholderFactory;
3228        let argv: Vec<String> = vec!["jest".into(), "--run".into()];
3229        assert!(!f.matches(&argv));
3230    }
3231
3232    #[test]
3233    fn vitest_factory_rewriter_adds_reporter_json() {
3234        let registry = default_registry();
3235        let result = registry.attach("vitest --run", &BeholderSelect::Auto, false);
3236        assert!(result.beholder.is_some(), "vitest beholder must attach");
3237        assert!(
3238            result.argv.contains(&"--reporter=json".to_string()),
3239            "rewriter must inject --reporter=json; got: {:?}",
3240            result.argv
3241        );
3242        assert!(
3243            result.status.text.contains("rewrite="),
3244            "rewrite must be surfaced in status; got: {}",
3245            result.status.text
3246        );
3247    }
3248
3249    #[test]
3250    fn vitest_factory_no_rewrite_when_reporter_json_present() {
3251        let registry = default_registry();
3252        let result = registry.attach("vitest --reporter=json --run", &BeholderSelect::Auto, false);
3253        assert!(result.beholder.is_some());
3254        let count = result.argv.iter().filter(|a| a.as_str() == "--reporter=json").count();
3255        assert_eq!(count, 1, "--reporter=json must appear exactly once; got: {:?}", result.argv);
3256    }
3257
3258    #[test]
3259    fn vitest_factory_declares_tool_version_range() {
3260        let f = VitestBeholderFactory;
3261        let range = f.tool_version_range().expect("vitest factory must declare a version range");
3262        assert_eq!(range.min, Some("1.0.0"));
3263        assert!(range.max.is_none());
3264    }
3265
3266    // ─── JestBeholderFactory tests ────────────────────────────────────────────
3267
3268    #[test]
3269    fn jest_factory_matches_bare_jest() {
3270        let f = JestBeholderFactory;
3271        let argv: Vec<String> = vec!["jest".into()];
3272        assert!(f.matches(&argv));
3273    }
3274
3275    #[test]
3276    fn jest_factory_matches_jest_with_path() {
3277        let f = JestBeholderFactory;
3278        let argv: Vec<String> = vec!["jest".into(), "src/foo.test.ts".into()];
3279        assert!(f.matches(&argv));
3280    }
3281
3282    #[test]
3283    fn jest_factory_matches_when_json_already_set() {
3284        let f = JestBeholderFactory;
3285        let argv: Vec<String> = vec!["jest".into(), "--json".into()];
3286        assert!(f.matches(&argv), "must match when --json already present");
3287    }
3288
3289    #[test]
3290    fn jest_factory_declines_version() {
3291        let f = JestBeholderFactory;
3292        let argv: Vec<String> = vec!["jest".into(), "--version".into()];
3293        assert!(!f.matches(&argv));
3294    }
3295
3296    #[test]
3297    fn jest_factory_declines_output_file() {
3298        let f = JestBeholderFactory;
3299        let argv: Vec<String> = vec!["jest".into(), "--outputFile=results.json".into()];
3300        assert!(!f.matches(&argv), "must decline when --outputFile is set (JSON goes to file)");
3301    }
3302
3303    #[test]
3304    fn jest_factory_declines_non_jest_command() {
3305        let f = JestBeholderFactory;
3306        let argv: Vec<String> = vec!["vitest".into()];
3307        assert!(!f.matches(&argv));
3308    }
3309
3310    #[test]
3311    fn jest_factory_rewriter_adds_json_flag() {
3312        let registry = default_registry();
3313        let result = registry.attach("jest src/", &BeholderSelect::Auto, false);
3314        assert!(result.beholder.is_some(), "jest beholder must attach");
3315        assert!(
3316            result.argv.contains(&"--json".to_string()),
3317            "rewriter must inject --json; got: {:?}",
3318            result.argv
3319        );
3320        assert!(
3321            result.status.text.contains("rewrite="),
3322            "rewrite must be surfaced in status; got: {}",
3323            result.status.text
3324        );
3325    }
3326
3327    #[test]
3328    fn jest_factory_no_rewrite_when_json_present() {
3329        let registry = default_registry();
3330        let result = registry.attach("jest --json src/", &BeholderSelect::Auto, false);
3331        assert!(result.beholder.is_some());
3332        let count = result.argv.iter().filter(|a| a.as_str() == "--json").count();
3333        assert_eq!(count, 1, "--json must appear exactly once; got: {:?}", result.argv);
3334    }
3335
3336    #[test]
3337    fn jest_factory_declares_tool_version_range() {
3338        let f = JestBeholderFactory;
3339        let range = f.tool_version_range().expect("jest factory must declare a version range");
3340        assert_eq!(range.min, Some("27.0.0"));
3341        assert!(range.max.is_none());
3342    }
3343
3344    // ─── BunTestBeholderFactory tests ─────────────────────────────────────────
3345
3346    #[test]
3347    fn bun_test_factory_matches_bun_test() {
3348        let f = BunTestBeholderFactory;
3349        let argv: Vec<String> = vec!["bun".into(), "test".into()];
3350        assert!(f.matches(&argv));
3351    }
3352
3353    #[test]
3354    fn bun_test_factory_matches_bun_test_with_path() {
3355        let f = BunTestBeholderFactory;
3356        let argv: Vec<String> = vec!["bun".into(), "test".into(), "src/".into()];
3357        assert!(f.matches(&argv));
3358    }
3359
3360    #[test]
3361    fn bun_test_factory_matches_when_reporter_json_already_set() {
3362        let f = BunTestBeholderFactory;
3363        let argv: Vec<String> = vec!["bun".into(), "test".into(), "--reporter=json".into()];
3364        assert!(f.matches(&argv));
3365    }
3366
3367    #[test]
3368    fn bun_test_factory_declines_bare_bun() {
3369        let f = BunTestBeholderFactory;
3370        let argv: Vec<String> = vec!["bun".into(), "run".into(), "build.ts".into()];
3371        assert!(!f.matches(&argv));
3372    }
3373
3374    #[test]
3375    fn bun_test_factory_declines_non_json_reporter() {
3376        let f = BunTestBeholderFactory;
3377        let argv: Vec<String> = vec!["bun".into(), "test".into(), "--reporter=junit".into()];
3378        assert!(!f.matches(&argv));
3379    }
3380
3381    #[test]
3382    fn bun_test_factory_declines_version() {
3383        let f = BunTestBeholderFactory;
3384        let argv: Vec<String> = vec!["bun".into(), "test".into(), "--version".into()];
3385        assert!(!f.matches(&argv));
3386    }
3387
3388    #[test]
3389    fn bun_test_factory_rewriter_adds_reporter_json() {
3390        let registry = default_registry();
3391        let result = registry.attach("bun test", &BeholderSelect::Auto, false);
3392        assert!(result.beholder.is_some(), "bun-test beholder must attach");
3393        assert!(
3394            result.argv.contains(&"--reporter=json".to_string()),
3395            "rewriter must inject --reporter=json; got: {:?}",
3396            result.argv
3397        );
3398    }
3399
3400    #[test]
3401    fn bun_test_factory_declares_tool_version_range() {
3402        let f = BunTestBeholderFactory;
3403        let range = f.tool_version_range().expect("bun-test factory must declare a version range");
3404        assert_eq!(range.min, Some("1.0.0"));
3405        assert!(range.max.is_none());
3406    }
3407
3408    // ─── JsTestBeholder on_done tests ─────────────────────────────────────────
3409
3410    fn js_test_report_json(success: bool, passed: u32, failed: u32) -> String {
3411        format!(
3412            r#"{{
3413                "success": {success},
3414                "numTotalTests": {total},
3415                "numPassedTests": {passed},
3416                "numFailedTests": {failed},
3417                "numPendingTests": 0,
3418                "testResults": []
3419            }}"#,
3420            total = passed + failed,
3421        )
3422    }
3423
3424    #[test]
3425    fn js_test_beholder_on_done_success_summary() {
3426        use crate::types::TaskRunId;
3427        let json = js_test_report_json(true, 5, 0);
3428        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3429        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3430        let events = b.on_done(&TaskRunId::new(), 0);
3431        assert_eq!(events.len(), 1, "success run must produce one summary event");
3432        let ev = &events[0];
3433        assert_eq!(ev.target, "vitest");
3434        assert_eq!(ev.level, Level::Info);
3435        assert_eq!(ev.msg, "5 passed");
3436        assert_eq!(ev.fields["build"]["success"], true);
3437        assert_eq!(ev.fields["test"]["passed"], 5u32);
3438        assert_eq!(ev.fields["test"]["failed"], 0u32);
3439    }
3440
3441    #[test]
3442    fn js_test_beholder_on_done_failure_summary() {
3443        use crate::types::TaskRunId;
3444        let json = js_test_report_json(false, 3, 2);
3445        let mut b = JsTestBeholder::new("jest", "27.0", jest_adjust_argv);
3446        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3447        let events = b.on_done(&TaskRunId::new(), 0);
3448        assert_eq!(events.len(), 1, "no assertion results → only summary event");
3449        let ev = &events[0];
3450        assert_eq!(ev.target, "jest");
3451        assert_eq!(ev.level, Level::Error);
3452        assert_eq!(ev.msg, "2 failed, 3 passed");
3453    }
3454
3455    #[test]
3456    fn js_test_beholder_on_done_emits_failed_assertion_events() {
3457        use crate::types::TaskRunId;
3458        let json = r#"{
3459            "success": false,
3460            "numTotalTests": 2,
3461            "numPassedTests": 1,
3462            "numFailedTests": 1,
3463            "numPendingTests": 0,
3464            "testResults": [
3465                {
3466                    "testFilePath": "src/foo.test.ts",
3467                    "assertionResults": [
3468                        {
3469                            "fullName": "suite > passes",
3470                            "status": "passed",
3471                            "failureMessages": []
3472                        },
3473                        {
3474                            "fullName": "suite > fails",
3475                            "status": "failed",
3476                            "failureMessages": ["Error: expected 1 to equal 2\n  at foo (src/foo.test.ts:10)"]
3477                        }
3478                    ]
3479                }
3480            ]
3481        }"#;
3482        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3483        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3484        let run_id = TaskRunId::new();
3485        let events = b.on_done(&run_id, 0);
3486        // summary + one failure event (passed assertion is skipped)
3487        assert_eq!(events.len(), 2, "expected summary + one failure event; got: {events:?}");
3488        let failure = &events[1];
3489        assert_eq!(failure.level, Level::Error);
3490        assert_eq!(failure.target, "vitest::test");
3491        assert_eq!(failure.msg, "Error: expected 1 to equal 2");
3492        assert_eq!(failure.fields["test"]["name"], "suite > fails");
3493        assert_eq!(failure.fields["file"]["path"], "src/foo.test.ts");
3494    }
3495
3496    #[test]
3497    fn js_test_beholder_on_done_multiple_suites_and_failures() {
3498        use crate::types::TaskRunId;
3499        let json = r#"{
3500            "success": false,
3501            "numTotalTests": 4,
3502            "numPassedTests": 2,
3503            "numFailedTests": 2,
3504            "numPendingTests": 0,
3505            "testResults": [
3506                {
3507                    "testFilePath": "src/a.test.ts",
3508                    "assertionResults": [
3509                        { "fullName": "A passes", "status": "passed", "failureMessages": [] },
3510                        { "fullName": "A fails", "status": "failed", "failureMessages": ["err A"] }
3511                    ]
3512                },
3513                {
3514                    "testFilePath": "src/b.test.ts",
3515                    "assertionResults": [
3516                        { "fullName": "B passes", "status": "passed", "failureMessages": [] },
3517                        { "fullName": "B fails", "status": "failed", "failureMessages": ["err B"] }
3518                    ]
3519                }
3520            ]
3521        }"#;
3522        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3523        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3524        let events = b.on_done(&TaskRunId::new(), 0);
3525        // summary + 2 failure events
3526        assert_eq!(events.len(), 3);
3527        assert_eq!(events[1].fields["file"]["path"], "src/a.test.ts");
3528        assert_eq!(events[2].fields["file"]["path"], "src/b.test.ts");
3529    }
3530
3531    #[test]
3532    fn js_test_beholder_on_done_empty_buffer_returns_no_events() {
3533        use crate::types::TaskRunId;
3534        let b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3535        // Do not call parse_chunk — buffer is empty.
3536        let mut b = b;
3537        let events = b.on_done(&TaskRunId::new(), 0);
3538        assert!(events.is_empty());
3539    }
3540
3541    #[test]
3542    fn js_test_beholder_on_done_invalid_json_flags_unknown_format() {
3543        use crate::types::TaskRunId;
3544        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3545        b.parse_chunk(&dummy_chunk(b"not json"));
3546        let events = b.on_done(&TaskRunId::new(), 0);
3547        assert!(events.is_empty());
3548        assert!(b.unknown_format_reason().is_some());
3549    }
3550
3551    #[test]
3552    fn js_test_beholder_on_done_chunks_split_across_calls() {
3553        use crate::types::TaskRunId;
3554        let json = r#"{"success":true,"numTotalTests":1,"numPassedTests":1,"numFailedTests":0,"numPendingTests":0,"testResults":[]}"#;
3555        let mid = json.len() / 2;
3556        let mut b = JsTestBeholder::new("jest", "27.0", jest_adjust_argv);
3557        b.parse_chunk(&dummy_chunk(json[..mid].as_bytes()));
3558        b.parse_chunk(&dummy_chunk(json[mid..].as_bytes()));
3559        let events = b.on_done(&TaskRunId::new(), 0);
3560        assert_eq!(events.len(), 1, "split chunks must still parse correctly");
3561        assert_eq!(events[0].level, Level::Info);
3562    }
3563
3564    #[test]
3565    fn js_test_beholder_failure_msg_uses_first_line_only() {
3566        use crate::types::TaskRunId;
3567        let json = r#"{
3568            "success": false,
3569            "numTotalTests": 1, "numPassedTests": 0, "numFailedTests": 1, "numPendingTests": 0,
3570            "testResults": [{
3571                "testFilePath": "x.test.ts",
3572                "assertionResults": [{
3573                    "fullName": "fails",
3574                    "status": "failed",
3575                    "failureMessages": ["Error: oops\n  at Object.<anonymous> (x.test.ts:5:5)\n  at ...]"]
3576                }]
3577            }]
3578        }"#;
3579        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3580        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3581        let events = b.on_done(&TaskRunId::new(), 0);
3582        assert_eq!(events.len(), 2);
3583        // Failure event msg must be the first line only, not the full stack trace.
3584        assert_eq!(events[1].msg, "Error: oops");
3585    }
3586
3587    // ─── PytestBeholderFactory tests ──────────────────────────────────────────
3588
3589    fn args(s: &str) -> Vec<String> { s.split_whitespace().map(str::to_owned).collect() }
3590
3591    #[test]
3592    fn pytest_factory_matches_direct() {
3593        let f = PytestBeholderFactory;
3594        assert!(f.matches(&args("pytest")));
3595        assert!(f.matches(&args("pytest tests/")));
3596        assert!(f.matches(&args("py.test -v")));
3597    }
3598
3599    #[test]
3600    fn pytest_factory_matches_python_m_pytest() {
3601        let f = PytestBeholderFactory;
3602        assert!(f.matches(&args("python -m pytest")));
3603        assert!(f.matches(&args("python3 -m pytest tests/")));
3604    }
3605
3606    #[test]
3607    fn pytest_factory_declines_version_and_help() {
3608        let f = PytestBeholderFactory;
3609        assert!(!f.matches(&args("pytest --version")));
3610        assert!(!f.matches(&args("pytest -V")));
3611        assert!(!f.matches(&args("pytest --help")));
3612        assert!(!f.matches(&args("pytest -h")));
3613    }
3614
3615    #[test]
3616    fn pytest_factory_declines_collect_only() {
3617        let f = PytestBeholderFactory;
3618        assert!(!f.matches(&args("pytest --collect-only")));
3619        assert!(!f.matches(&args("pytest --co")));
3620    }
3621
3622    #[test]
3623    fn pytest_factory_declines_non_pytest() {
3624        let f = PytestBeholderFactory;
3625        assert!(!f.matches(&args("cargo test")));
3626        assert!(!f.matches(&args("python script.py")));
3627        assert!(!f.matches(&args("python -m flask run")));
3628    }
3629
3630    #[test]
3631    fn pytest_factory_mode_is_parser() {
3632        assert!(matches!(PytestBeholderFactory.mode(), BeholderMode::Parser));
3633    }
3634
3635    // ─── PytestBeholder parse tests ───────────────────────────────────────────
3636
3637    fn pytest_output_chunk(lines: &str, seq: u32) -> OutputChunk {
3638        use crate::types::TaskRunId;
3639        OutputChunk {
3640            run_id: TaskRunId::new(),
3641            seq,
3642            offset_ms: 0,
3643            stream: crate::types::Stream::Stdout,
3644            bytes: lines.as_bytes().to_vec(),
3645        }
3646    }
3647
3648    #[test]
3649    fn pytest_beholder_parses_failure_lines() {
3650        let mut b = PytestBeholder::default();
3651        let chunk = pytest_output_chunk(
3652            "FAILED tests/test_foo.py::test_bar - AssertionError: assert 1 == 2\n\
3653             FAILED tests/test_baz.py::test_qux - ZeroDivisionError: division by zero\n",
3654            1,
3655        );
3656        let events = b.parse_chunk(&chunk);
3657        assert_eq!(events.len(), 2);
3658        assert_eq!(events[0].level, Level::Error);
3659        assert_eq!(events[0].target, "pytest::test");
3660        assert_eq!(events[0].msg, "AssertionError: assert 1 == 2");
3661        assert_eq!(events[0].fields["test"]["name"], "test_bar");
3662        assert_eq!(events[0].fields["file"]["path"], "tests/test_foo.py");
3663        assert_eq!(events[1].msg, "ZeroDivisionError: division by zero");
3664    }
3665
3666    #[test]
3667    fn pytest_beholder_parses_error_lines() {
3668        let mut b = PytestBeholder::default();
3669        let chunk = pytest_output_chunk(
3670            "ERROR tests/test_broken.py - ImportError: No module named 'foo'\n",
3671            1,
3672        );
3673        let events = b.parse_chunk(&chunk);
3674        assert_eq!(events.len(), 1);
3675        assert_eq!(events[0].msg, "ImportError: No module named 'foo'");
3676        assert_eq!(events[0].fields["file"]["path"], "tests/test_broken.py");
3677    }
3678
3679    #[test]
3680    fn pytest_beholder_failure_without_reason() {
3681        let mut b = PytestBeholder::default();
3682        let chunk = pytest_output_chunk("FAILED tests/test_foo.py::test_bar\n", 1);
3683        let events = b.parse_chunk(&chunk);
3684        assert_eq!(events.len(), 1);
3685        assert_eq!(events[0].msg, "FAILED tests/test_foo.py::test_bar");
3686    }
3687
3688    #[test]
3689    fn pytest_beholder_ignores_lines_without_py() {
3690        let mut b = PytestBeholder::default();
3691        // These should not produce events — no `.py` in the nodeid.
3692        let chunk = pytest_output_chunk(
3693            "FAILED some_other_thing\nERROR not_a_module\n",
3694            1,
3695        );
3696        let events = b.parse_chunk(&chunk);
3697        assert!(events.is_empty());
3698    }
3699
3700    #[test]
3701    fn pytest_beholder_on_done_success_summary() {
3702        use crate::types::TaskRunId;
3703        let mut b = PytestBeholder::default();
3704        let chunk = pytest_output_chunk(
3705            "collected 5 items\n\
3706             ========================= 5 passed in 0.85s ==========================\n",
3707            1,
3708        );
3709        b.parse_chunk(&chunk);
3710        let events = b.on_done(&TaskRunId::new(), 0);
3711        assert_eq!(events.len(), 1);
3712        let ev = &events[0];
3713        assert_eq!(ev.level, Level::Info);
3714        assert_eq!(ev.target, "pytest");
3715        assert_eq!(ev.msg, "5 passed");
3716        assert_eq!(ev.fields["test"]["passed"], 5u32);
3717        assert_eq!(ev.fields["test"]["failed"], 0u32);
3718        assert_eq!(ev.fields["build"]["success"], true);
3719    }
3720
3721    #[test]
3722    fn pytest_beholder_on_done_failure_summary() {
3723        use crate::types::TaskRunId;
3724        let mut b = PytestBeholder::default();
3725        let chunk = pytest_output_chunk(
3726            "FAILED tests/a.py::t1 - err\n\
3727             ======= 1 failed, 4 passed in 1.23s =======\n",
3728            1,
3729        );
3730        b.parse_chunk(&chunk);
3731        let events = b.on_done(&TaskRunId::new(), 0);
3732        assert_eq!(events.len(), 1);
3733        let ev = &events[0];
3734        assert_eq!(ev.level, Level::Error);
3735        assert_eq!(ev.msg, "1 failed, 4 passed");
3736        assert_eq!(ev.fields["test"]["failed"], 1u32);
3737        assert_eq!(ev.fields["test"]["passed"], 4u32);
3738        assert_eq!(ev.fields["build"]["success"], false);
3739    }
3740
3741    #[test]
3742    fn pytest_beholder_on_done_mixed_summary() {
3743        use crate::types::TaskRunId;
3744        let mut b = PytestBeholder::default();
3745        let chunk = pytest_output_chunk(
3746            "====== 2 failed, 1 error, 5 passed, 1 skipped in 3.14s ======\n",
3747            1,
3748        );
3749        b.parse_chunk(&chunk);
3750        let events = b.on_done(&TaskRunId::new(), 0);
3751        assert_eq!(events.len(), 1);
3752        let ev = &events[0];
3753        assert_eq!(ev.level, Level::Error);
3754        assert_eq!(ev.msg, "2 failed, 1 error, 5 passed, 1 skipped");
3755        assert_eq!(ev.fields["test"]["failed"], 2u32);
3756        assert_eq!(ev.fields["test"]["errors"], 1u32);
3757        assert_eq!(ev.fields["test"]["passed"], 5u32);
3758        assert_eq!(ev.fields["test"]["skipped"], 1u32);
3759    }
3760
3761    #[test]
3762    fn pytest_beholder_on_done_no_summary_returns_empty() {
3763        use crate::types::TaskRunId;
3764        let mut b = PytestBeholder::default();
3765        let events = b.on_done(&TaskRunId::new(), 0);
3766        assert!(events.is_empty());
3767    }
3768
3769    #[test]
3770    fn pytest_beholder_chunks_split_across_calls() {
3771        let line = "FAILED tests/foo.py::bar - AssertionError\n";
3772        let mid = line.len() / 2;
3773        let mut b = PytestBeholder::default();
3774        b.parse_chunk(&pytest_output_chunk(&line[..mid], 1));
3775        let ev1 = b.parse_chunk(&pytest_output_chunk(&line[mid..], 2));
3776        // The complete FAILED line should be emitted from the second chunk.
3777        assert_eq!(ev1.len(), 1);
3778        assert_eq!(ev1[0].msg, "AssertionError");
3779    }
3780
3781    // ─── parse_pytest_summary unit tests ──────────────────────────────────────
3782
3783    #[test]
3784    fn parse_pytest_summary_all_passed() {
3785        let s = parse_pytest_summary("5 passed in 0.85s").unwrap();
3786        assert_eq!(s.passed, 5);
3787        assert_eq!(s.failed, 0);
3788    }
3789
3790    #[test]
3791    fn parse_pytest_summary_mixed() {
3792        let s = parse_pytest_summary("2 failed, 5 passed, 1 skipped in 3.14s").unwrap();
3793        assert_eq!(s.failed, 2);
3794        assert_eq!(s.passed, 5);
3795        assert_eq!(s.skipped, 1);
3796        assert_eq!(s.errors, 0);
3797    }
3798
3799    #[test]
3800    fn parse_pytest_summary_with_errors() {
3801        let s = parse_pytest_summary("1 error, 3 passed in 1.0s").unwrap();
3802        assert_eq!(s.errors, 1);
3803        assert_eq!(s.passed, 3);
3804    }
3805
3806    #[test]
3807    fn parse_pytest_summary_rejects_garbage() {
3808        assert!(parse_pytest_summary("not a summary").is_err());
3809        assert!(parse_pytest_summary("").is_err());
3810    }
3811
3812    // ─── ViteBuildBeholderFactory tests ───────────────────────────────────────
3813
3814    #[test]
3815    fn vite_build_factory_matches_bare() {
3816        let f = ViteBuildBeholderFactory;
3817        assert!(f.matches(&args("vite build")));
3818        assert!(f.matches(&args("vite build --outDir dist")));
3819    }
3820
3821    #[test]
3822    fn vite_build_factory_declines_no_build_subcommand() {
3823        let f = ViteBuildBeholderFactory;
3824        assert!(!f.matches(&args("vite")));
3825        assert!(!f.matches(&args("vite preview")));
3826        assert!(!f.matches(&args("vite dev")));
3827    }
3828
3829    #[test]
3830    fn vite_build_factory_declines_help_and_version() {
3831        let f = ViteBuildBeholderFactory;
3832        assert!(!f.matches(&args("vite build --help")));
3833        assert!(!f.matches(&args("vite build -h")));
3834        assert!(!f.matches(&args("vite build --version")));
3835    }
3836
3837    #[test]
3838    fn vite_build_factory_declines_non_vite() {
3839        let f = ViteBuildBeholderFactory;
3840        assert!(!f.matches(&args("cargo build")));
3841        assert!(!f.matches(&args("tsc")));
3842    }
3843
3844    #[test]
3845    fn vite_build_factory_mode_is_parser() {
3846        assert!(matches!(ViteBuildBeholderFactory.mode(), BeholderMode::Parser));
3847    }
3848
3849    // ─── ViteBuildBeholder parse tests ────────────────────────────────────────
3850
3851    fn vite_chunk(lines: &str, seq: u32) -> OutputChunk {
3852        use crate::types::TaskRunId;
3853        OutputChunk {
3854            run_id: TaskRunId::new(),
3855            seq,
3856            offset_ms: 100,
3857            stream: crate::types::Stream::Stdout,
3858            bytes: lines.as_bytes().to_vec(),
3859        }
3860    }
3861
3862    #[test]
3863    fn vite_beholder_parses_warning_line() {
3864        let mut b = ViteBuildBeholder::default();
3865        let chunk = vite_chunk(
3866            "(!) Some chunks are larger than 500 kB after minification.\n",
3867            1,
3868        );
3869        let events = b.parse_chunk(&chunk);
3870        assert_eq!(events.len(), 1);
3871        assert_eq!(events[0].level, Level::Warn);
3872        assert_eq!(events[0].target, "vite-build::warning");
3873        assert_eq!(events[0].msg, "Some chunks are larger than 500 kB after minification.");
3874        assert_eq!(events[0].fields["build"]["warning"], true);
3875    }
3876
3877    #[test]
3878    fn vite_beholder_parses_bundle_line_with_gzip() {
3879        let mut b = ViteBuildBeholder::default();
3880        let chunk = vite_chunk(
3881            "dist/assets/index-CKBFsjV8.js   141.01 kB \u{2502} gzip:  45.33 kB\n",
3882            1,
3883        );
3884        let events = b.parse_chunk(&chunk);
3885        assert_eq!(events.len(), 1);
3886        let ev = &events[0];
3887        assert_eq!(ev.level, Level::Info);
3888        assert_eq!(ev.target, "vite-build::bundle");
3889        assert_eq!(ev.fields["file"]["path"], "dist/assets/index-CKBFsjV8.js");
3890        assert!((ev.fields["build"]["size_kb"].as_f64().unwrap() - 141.01).abs() < 0.01);
3891        assert!((ev.fields["build"]["gzip_kb"].as_f64().unwrap() - 45.33).abs() < 0.01);
3892    }
3893
3894    #[test]
3895    fn vite_beholder_parses_bundle_line_no_gzip() {
3896        let mut b = ViteBuildBeholder::default();
3897        let chunk = vite_chunk("dist/index.html   0.46 kB\n", 1);
3898        let events = b.parse_chunk(&chunk);
3899        assert_eq!(events.len(), 1);
3900        assert_eq!(events[0].target, "vite-build::bundle");
3901        assert!((events[0].fields["build"]["size_kb"].as_f64().unwrap() - 0.46).abs() < 0.01);
3902        assert!(events[0].fields["build"].get("gzip_kb").is_none()
3903            || events[0].fields["build"]["gzip_kb"].is_null());
3904    }
3905
3906    #[test]
3907    fn vite_beholder_parses_built_timing() {
3908        let mut b = ViteBuildBeholder::default();
3909        let chunk = vite_chunk("\u{2713} built in 2.43s\n", 1);
3910        let events = b.parse_chunk(&chunk);
3911        assert_eq!(events.len(), 1);
3912        let ev = &events[0];
3913        assert_eq!(ev.level, Level::Info);
3914        assert_eq!(ev.target, "vite-build");
3915        assert_eq!(ev.fields["build"]["duration_ms"], 2430u64);
3916        assert_eq!(ev.fields["build"]["success"], true);
3917    }
3918
3919    #[test]
3920    fn vite_beholder_parses_built_timing_no_checkmark() {
3921        let mut b = ViteBuildBeholder::default();
3922        let chunk = vite_chunk("built in 0.99s\n", 1);
3923        let events = b.parse_chunk(&chunk);
3924        assert_eq!(events.len(), 1);
3925        assert_eq!(events[0].fields["build"]["duration_ms"], 990u64);
3926    }
3927
3928    #[test]
3929    fn vite_beholder_ignores_unrelated_lines() {
3930        let mut b = ViteBuildBeholder::default();
3931        let chunk = vite_chunk(
3932            "vite v5.4.0 building for production...\n\
3933             \u{2713} 1234 modules transformed.\n",
3934            1,
3935        );
3936        let events = b.parse_chunk(&chunk);
3937        assert!(events.is_empty(), "got unexpected events: {events:?}");
3938    }
3939
3940    #[test]
3941    fn vite_beholder_multiple_events_one_chunk() {
3942        let mut b = ViteBuildBeholder::default();
3943        let chunk = vite_chunk(
3944            "(!) Use of eval is strongly discouraged.\n\
3945             dist/assets/index.js   50.00 kB \u{2502} gzip: 15.00 kB\n\
3946             \u{2713} built in 1.50s\n",
3947            1,
3948        );
3949        let events = b.parse_chunk(&chunk);
3950        assert_eq!(events.len(), 3);
3951        assert_eq!(events[0].level, Level::Warn);
3952        assert_eq!(events[1].target, "vite-build::bundle");
3953        assert_eq!(events[2].target, "vite-build");
3954    }
3955
3956    #[test]
3957    fn vite_beholder_chunks_split_across_calls() {
3958        let line = "(!) Large chunk warning.\n";
3959        let mid = line.len() / 2;
3960        let mut b = ViteBuildBeholder::default();
3961        let ev1 = b.parse_chunk(&vite_chunk(&line[..mid], 1));
3962        let ev2 = b.parse_chunk(&vite_chunk(&line[mid..], 2));
3963        assert!(ev1.is_empty(), "partial line should not emit");
3964        assert_eq!(ev2.len(), 1);
3965        assert_eq!(ev2[0].level, Level::Warn);
3966    }
3967
3968    // ─── parse_vite_built_ms unit tests ───────────────────────────────────────
3969
3970    #[test]
3971    fn parse_vite_built_ms_with_checkmark() {
3972        assert_eq!(parse_vite_built_ms("\u{2713} built in 2.43s"), Some(2430));
3973    }
3974
3975    #[test]
3976    fn parse_vite_built_ms_bare() {
3977        assert_eq!(parse_vite_built_ms("built in 0.50s"), Some(500));
3978    }
3979
3980    #[test]
3981    fn parse_vite_built_ms_not_a_timing_line() {
3982        assert!(parse_vite_built_ms("vite v5.0.0 building for production...").is_none());
3983        assert!(parse_vite_built_ms("").is_none());
3984    }
3985}