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