diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index 7d84954..916b4ff 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -7,6 +7,7 @@ mod update; use std::io::Write; use std::process::ExitCode; +use std::sync::Arc; use clap::{CommandFactory, Parser}; use wright_driver::config::{InputSpec, OutputFormat, SessionConfig, SourceKind}; @@ -199,8 +200,11 @@ fn run_workflow(command: Command) -> ExitCode { ConvertTargetArg::Opy => wright_driver::ConvertTarget::Opy, ConvertTargetArg::Ostw => wright_driver::ConvertTarget::Ostw, }; - let _activity = presentation.activity(); + let activity = Arc::new(presentation.activity()); + session.set_progress_observer(activity.clone()); let envelope = session.convert(target); + session.clear_progress_observer(); + drop(activity); let code = envelope.exit; present::render(&envelope, presentation); code @@ -245,8 +249,11 @@ fn run_command( run: fn(&mut wright_driver::CompilerSession) -> wright_driver::Envelope, presentation: present::Presentation, ) -> u8 { - let _activity = presentation.activity(); + let activity = Arc::new(presentation.activity()); + session.set_progress_observer(activity.clone()); let envelope = run(session); + session.clear_progress_observer(); + drop(activity); let code = envelope.exit; present::render(&envelope, presentation); code diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index d56cdf3..943b57d 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -5,8 +5,10 @@ //! Actions. JSON and source artifacts bypass every human/CI renderer. use std::io::{IsTerminal, Write}; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, Ordering}, }; use std::thread; @@ -14,6 +16,7 @@ use std::time::Duration; use wright_driver::Severity; use wright_driver::config::OutputFormat; +use wright_driver::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; use wright_driver::result::Envelope; use crate::cli::{ColorArg, CommonArgs, OutputFormatArg, RendererArg}; @@ -32,6 +35,11 @@ pub(crate) struct Presentation { pub(crate) struct Activity { done: Arc, visible: Arc, + status: Arc>>, + output: Arc>, + #[cfg(test)] + #[allow(dead_code)] + frame: Arc, handle: Option>, } @@ -40,6 +48,10 @@ impl Activity { Self { done: Arc::new(AtomicBool::new(true)), visible: Arc::new(AtomicBool::new(false)), + status: Arc::new(Mutex::new(None)), + output: Arc::new(Mutex::new(())), + #[cfg(test)] + frame: Arc::new(AtomicUsize::new(0)), handle: None, } } @@ -47,24 +59,84 @@ impl Activity { fn start() -> Self { let done = Arc::new(AtomicBool::new(false)); let visible = Arc::new(AtomicBool::new(false)); + let status = Arc::new(Mutex::new(None)); + let output = Arc::new(Mutex::new(())); + #[cfg(test)] + let frame = Arc::new(AtomicUsize::new(0)); + write_activity_line(&output, None, None); + visible.store(true, Ordering::Release); let thread_done = Arc::clone(&done); - let thread_visible = Arc::clone(&visible); + let thread_status = Arc::clone(&status); + let thread_output = Arc::clone(&output); + #[cfg(test)] + let thread_frame = Arc::clone(&frame); let handle = thread::spawn(move || { - thread::sleep(Duration::from_millis(150)); - if !thread_done.load(Ordering::Acquire) { - eprint!("wright: working…"); - let _ = std::io::stderr().flush(); - thread_visible.store(true, Ordering::Release); + thread::sleep(Duration::from_millis(60)); + let mut frame = 0; + while !thread_done.load(Ordering::Acquire) { + let event = *thread_status.lock().expect("activity status lock"); + write_activity_line(&thread_output, event, Some(SPINNER[frame])); + frame = (frame + 1) % SPINNER.len(); + #[cfg(test)] + thread_frame.store(frame, Ordering::Release); + thread::sleep(Duration::from_millis(80)); } }); Self { done, visible, + status, + output, + #[cfg(test)] + frame, handle: Some(handle), } } } +const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +impl ProgressObserver for Activity { + fn on_progress(&self, event: ProgressEvent) { + if self.done.load(Ordering::Acquire) { + return; + } + *self.status.lock().expect("activity status lock") = Some(event); + write_activity_line(&self.output, Some(event), None); + } +} + +fn write_activity_line(output: &Mutex<()>, event: Option, spinner: Option) { + let _guard = output.lock().expect("activity output lock"); + let label = event + .map(progress_label) + .unwrap_or("Starting workflow".to_string()); + match spinner { + Some(spinner) => eprint!("\r\x1b[2K\r {spinner} {label}…"), + None => eprint!("\r\x1b[2K\r {label}…"), + } + let _ = std::io::stderr().flush(); +} + +fn progress_label(event: ProgressEvent) -> String { + let label = match event.phase { + ProgressPhase::InputResolution => "Resolving input".to_string(), + ProgressPhase::ProjectLoading => "Loading project".to_string(), + ProgressPhase::Parsing => "Parsing".to_string(), + ProgressPhase::Validation => "Validating".to_string(), + ProgressPhase::Lowering => "Lowering".to_string(), + ProgressPhase::SemanticAnalysis => "Resolving semantics".to_string(), + ProgressPhase::Linting => "Running lint rules".to_string(), + ProgressPhase::Emission => "Emitting Workshop".to_string(), + ProgressPhase::Conversion => "Reconstructing source".to_string(), + }; + match (event.count, event.unit) { + (Some(count), Some(ProgressUnit::Files)) => format!("{label} {count} files"), + (Some(count), Some(ProgressUnit::Rules)) => format!("{label} {count} rules"), + _ => label, + } +} + impl Drop for Activity { fn drop(&mut self) { self.done.store(true, Ordering::Release); @@ -988,7 +1060,7 @@ mod tests { } #[test] - fn activity_becomes_visible_only_after_delay() { + fn activity_is_visible_immediately_and_accepts_phase_updates() { let terminal = Presentation::resolve( OutputFormat::Text, RendererArg::Terminal, @@ -996,9 +1068,23 @@ mod tests { environment(), ); let activity = terminal.activity(); - assert!(!activity.visible.load(Ordering::Acquire)); - thread::sleep(Duration::from_millis(180)); assert!(activity.visible.load(Ordering::Acquire)); + assert_eq!(*activity.status.lock().unwrap(), None); + activity.on_progress(ProgressEvent::with_count( + ProgressPhase::Linting, + 12, + ProgressUnit::Rules, + )); + assert_eq!( + *activity.status.lock().unwrap(), + Some(ProgressEvent::with_count( + ProgressPhase::Linting, + 12, + ProgressUnit::Rules, + )) + ); + thread::sleep(Duration::from_millis(150)); + assert!(activity.frame.load(Ordering::Acquire) > 0); } #[test] diff --git a/crates/wright-cli/tests/cli.rs b/crates/wright-cli/tests/cli.rs index c234253..0b863ce 100644 --- a/crates/wright-cli/tests/cli.rs +++ b/crates/wright-cli/tests/cli.rs @@ -75,6 +75,34 @@ fn run_with_env(args: &[&str], variables: &[(&str, &str)]) -> std::process::Outp command.output().expect("wright runs") } +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn run_in_tty(args: &[&str]) -> std::process::Output { + #[cfg(target_os = "linux")] + let command = std::iter::once(wright()) + .chain(args.iter().copied()) + .map(|arg| { + if arg + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || "-_/.:".contains(ch)) + { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" "); + let mut script = Command::new("script"); + #[cfg(target_os = "linux")] + script.args(["-qefc", &command, "/dev/null"]); + #[cfg(target_os = "macos")] + script.args(["-q", "/dev/null", wright()]).args(args); + script + .env("TERM", "xterm") + .output() + .expect("script is available") +} + fn run_with_stdin(args: &[&str], stdin: &str) -> std::process::Output { let mut child = Command::new(wright()) .args(args) @@ -583,6 +611,98 @@ fn stdout_stderr_separation_holds_in_both_modes() { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +fn tty_progress_stops_and_clears_before_final_render() { + let path = + workspace_root().join("compatibility/fixtures/real-world/overpy-pixelart/pixelart.opy"); + let output = run_in_tty(&[ + "analyze", + path.to_str().unwrap(), + "--kind", + "opy", + "--renderer", + "terminal", + "--color", + "never", + ]); + assert!(output.status.success()); + let mut transcript = output.stdout; + transcript.extend_from_slice(&output.stderr); + let transcript = String::from_utf8_lossy(&transcript); + let final_render = transcript + .find("PASS analyze") + .expect("final analyze render is present"); + let before_final = &transcript[..final_render]; + assert!(before_final.contains("Starting workflow")); + assert!(before_final.contains("Resolving input")); + assert!(before_final.contains("Parsing")); + assert!(before_final.contains("Resolving semantics")); + let cleared = before_final + .rfind("\x1b[2K") + .expect("activity line is cleared before final render"); + assert!(cleared < final_render); + assert!(!transcript.contains("working…PASS")); + + let lint = run_in_tty(&[ + "lint", + path.to_str().unwrap(), + "--kind", + "opy", + "--renderer", + "terminal", + "--color", + "never", + ]); + assert!(lint.status.success()); + let mut lint_transcript = lint.stdout; + lint_transcript.extend_from_slice(&lint.stderr); + let lint_transcript = String::from_utf8_lossy(&lint_transcript); + assert!(lint_transcript.contains("Running lint rules")); + assert!(lint_transcript.contains(" rules…")); +} + +#[test] +fn non_interactive_renderers_have_no_progress_artifacts() { + let source = std::fs::read_to_string( + workspace_root().join("compatibility/fixtures/synthetic/control-flow/source.opy"), + ) + .unwrap(); + let path = temp_file("basic.opy", &source); + for renderer in ["plain", "github-actions"] { + let output = run_with_env( + &[ + "analyze", + path.to_str().unwrap(), + "--renderer", + renderer, + "--color", + "always", + ], + &[("GITHUB_ACTIONS", "true")], + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !combined.contains("Resolving input"), + "{renderer}: {combined}" + ); + assert!( + !combined.contains("Running lint rules"), + "{renderer}: {combined}" + ); + assert!(!combined.contains("⠋"), "{renderer}: {combined}"); + } + let json = run(&["analyze", path.to_str().unwrap(), "--format", "json"]); + assert!(json.status.success()); + assert!(json.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&json.stdout).contains("Resolving input")); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + #[test] fn explicit_locale_override_is_accepted() { let path = temp_file("basic.txt", &corpus_workshop("synthetic/basic-rule")); diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index 14b36a7..e6d58cf 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -17,6 +17,7 @@ pub mod diag; pub mod edit; pub mod input; pub mod opy; +pub mod progress; pub mod provider_edit; pub mod result; pub mod service; @@ -26,6 +27,7 @@ pub mod workshop_provider; pub use config::{InputSpec, LintConfig, OutputFormat, SessionConfig, SourceKind}; pub use diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; pub use input::{ResolvedInput, sha256_hex}; +pub use progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; pub use result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, Envelope, InspectResult, LintResult, RESULT_CONTRACT, diff --git a/crates/wright-driver/src/progress.rs b/crates/wright-driver/src/progress.rs new file mode 100644 index 0000000..41566da --- /dev/null +++ b/crates/wright-driver/src/progress.rs @@ -0,0 +1,71 @@ +//! Transport-neutral workflow progress events. +//! +//! The driver reports semantic workflow boundaries without terminal strings, +//! ANSI, timing, or presentation policy. CLI and embedding consumers may +//! observe these events independently. + +use std::sync::Arc; + +/// A real orchestration phase exposed to interested consumers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProgressPhase { + /// Resolve the input path/stdin and detect its source kind. + InputResolution, + /// Load a multi-file project boundary. + ProjectLoading, + /// Parse source or protocol input. + Parsing, + /// Validate a parsed model. + Validation, + /// Lower a validated model into the shared representation. + Lowering, + /// Run semantic queries or analysis. + SemanticAnalysis, + /// Execute configured lint rules. + Linting, + /// Emit compiled Workshop text. + Emission, + /// Reconstruct a source-language project. + Conversion, +} + +/// The unit associated with optional bounded phase metadata. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProgressUnit { + Files, + Rules, +} + +/// A phase transition emitted by [`crate::CompilerSession`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProgressEvent { + pub phase: ProgressPhase, + pub count: Option, + pub unit: Option, +} + +impl ProgressEvent { + pub const fn new(phase: ProgressPhase) -> Self { + Self { + phase, + count: None, + unit: None, + } + } + + pub const fn with_count(phase: ProgressPhase, count: usize, unit: ProgressUnit) -> Self { + Self { + phase, + count: Some(count), + unit: Some(unit), + } + } +} + +/// Receives semantic workflow phase transitions from a compiler session. +pub trait ProgressObserver: Send + Sync { + fn on_progress(&self, event: ProgressEvent); +} + +/// Convenience alias for observers shared with a running session. +pub type SharedProgressObserver = Arc; diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 4e621a6..3fbbf38 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -19,6 +19,7 @@ use crate::WorkshopProvider; use crate::config::{SessionConfig, SourceKind}; use crate::diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; use crate::input::{self, ResolvedInput}; +use crate::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; use crate::result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, Envelope, InspectResult, LintResult, OstwFileSummary, OstwProjectSummary, exit_code_from, @@ -52,6 +53,7 @@ pub struct CompilerSession { catalog: workshop_rs::catalog::Catalog, loaded: Option, diagnostics: Vec, + progress_observer: Option>, } impl CompilerSession { @@ -69,9 +71,26 @@ impl CompilerSession { catalog, loaded: None, diagnostics: Vec::new(), + progress_observer: None, }) } + /// Attach a transport-neutral observer for real workflow phase events. + pub fn set_progress_observer(&mut self, observer: Arc) { + self.progress_observer = Some(observer); + } + + /// Detach the current progress observer before a caller renders a result. + pub fn clear_progress_observer(&mut self) { + self.progress_observer = None; + } + + fn progress(&self, event: ProgressEvent) { + if let Some(observer) = &self.progress_observer { + observer.on_progress(event); + } + } + /// Load (or reuse) the validated program for this session. /// /// Loading is idempotent: repeated calls return the same program without @@ -81,21 +100,26 @@ impl CompilerSession { if let Some(loaded) = &self.loaded { return Ok(loaded.clone()); } + self.progress(ProgressEvent::new(ProgressPhase::InputResolution)); let mut resolved = input::resolve(&self.config)?; if resolved.kind == SourceKind::Ostw { + self.progress(ProgressEvent::new(ProgressPhase::ProjectLoading)); return self.load_ostw(&mut resolved); } let mut program = match resolved.kind { SourceKind::Workshop => { + self.progress(ProgressEvent::new(ProgressPhase::Parsing)); let (program, locale) = self.load_workshop(&resolved)?; resolved.origin.locale = Some(locale); program } SourceKind::Protocol => { + self.progress(ProgressEvent::new(ProgressPhase::Parsing)); let json = resolved.text.clone(); self.load_protocol(&json, &resolved)? } SourceKind::Opy => { + self.progress(ProgressEvent::new(ProgressPhase::Parsing)); if opy::adapter_fallback_requested() { let json = opy::run_adapter(&resolved)?; self.load_protocol(&json, &resolved)? @@ -158,6 +182,8 @@ impl CompilerSession { /// project outcome (file registry + project and semantic diagnostics) is /// retained on the session so spans keep their multi-file provenance. fn load_ostw(&mut self, resolved: &mut ResolvedInput) -> Result { + self.progress(ProgressEvent::new(ProgressPhase::Parsing)); + self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); let relative = resolved .path .as_ref() @@ -194,9 +220,11 @@ impl CompilerSession { model: &wright_ir::hir::Program, resolved: &ResolvedInput, ) -> Result { + self.progress(ProgressEvent::new(ProgressPhase::Validation)); model .validate() .map_err(|error| ir_diag("validation-error", Stage::Validation, error, resolved))?; + self.progress(ProgressEvent::new(ProgressPhase::Lowering)); let program = wright_ir::lower::lower(model) .map_err(|error| ir_diag("lower-error", Stage::Lowering, error, resolved))?; program @@ -273,6 +301,7 @@ impl CompilerSession { &context, ) .map_err(|error| workshop_diag(error, resolved))?; + self.progress(ProgressEvent::new(ProgressPhase::Validation)); program .validate() .map_err(|error| ir_diag("validation-error", Stage::Validation, error, resolved))?; @@ -299,9 +328,11 @@ impl CompilerSession { // (settings domain checks against the emission table, #86); the // adapter path validates inside parse_str, so this is a double // validation there — acceptable. + self.progress(ProgressEvent::new(ProgressPhase::Validation)); protocol .validate() .map_err(|error| hir_diag(error, resolved))?; + self.progress(ProgressEvent::new(ProgressPhase::Lowering)); let model = protocol .to_ir() .map_err(|error| ir_diag("convert-error", Stage::Lowering, error, resolved))?; @@ -378,6 +409,7 @@ impl CompilerSession { .clone() .map(|locale| workshop_rs::catalog::Locale::new(&locale)) .unwrap_or_else(|| workshop_rs::catalog::Locale::new("en-US")); + self.progress(ProgressEvent::new(ProgressPhase::Emission)); let text = workshop_rs::emitter::emit(&loaded.program, &self.catalog, &locale) .map_err(|error| workshop_diag(error, &loaded.input))?; let sha256 = input_identity(&text); @@ -415,6 +447,7 @@ impl CompilerSession { }, ); } + self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); self.attach_workshop_completeness(&loaded); self.finish(command, CheckResult { ostw: None }) } @@ -442,6 +475,7 @@ impl CompilerSession { return self.finish(command, AnalyzeResult::default()); } }; + self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); let mut program = service_response(&service, &Request::Program); if let serde_json::Value::Object(object) = &mut program { // The service also supports the legacy findings query for agents, @@ -474,6 +508,7 @@ impl CompilerSession { return self.finish(command, InspectResult::default()); } }; + self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); let program = service_response(&service, &Request::Program); let rules = service_response(&service, &Request::ListRules); let symbols = service_response(&service, &Request::ListSymbols { kind: None }); @@ -532,8 +567,18 @@ impl CompilerSession { return self.finish(command, LintResult::default()); } }; + self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); let program = service_response(&service, &Request::Program); let lint_rules = service_response(&service, &Request::LintRules); + let lint_rule_count = lint_rules + .pointer("/rules") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + self.progress(ProgressEvent::with_count( + ProgressPhase::Linting, + lint_rule_count, + ProgressUnit::Rules, + )); let mut findings = service_response(&service, &Request::GetFindings); resolve_finding_span_paths(&mut findings, &loaded); let (rules, config) = match lint_rules { @@ -595,6 +640,7 @@ impl CompilerSession { )); return self.finish(command, ConvertResult::default()); } + self.progress(ProgressEvent::new(ProgressPhase::Conversion)); let text = match target { ConvertTarget::Opy => self.convert_opy(&loaded), ConvertTarget::Ostw => self.convert_ostw(&loaded), diff --git a/crates/wright-driver/tests/driver.rs b/crates/wright-driver/tests/driver.rs index f65f57c..147bcc0 100644 --- a/crates/wright-driver/tests/driver.rs +++ b/crates/wright-driver/tests/driver.rs @@ -3,10 +3,13 @@ //! CLI and library consumers share this single orchestration path. use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; -use wright_driver::CompilerSession; use wright_driver::config::{InputSpec, SessionConfig, SourceKind}; use wright_driver::result::exit; +use wright_driver::{ + CompilerSession, ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit, +}; fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") @@ -43,6 +46,81 @@ fn corpus_source_opy(fixture_id: &str) -> String { .unwrap() } +#[derive(Default)] +struct RecordingProgress(Mutex>); + +impl ProgressObserver for RecordingProgress { + fn on_progress(&self, event: ProgressEvent) { + self.0.lock().unwrap().push(event); + } +} + +#[test] +fn progress_events_follow_the_real_workflow_boundaries() { + let path = temp_file("progress.opy", &corpus_source_opy("synthetic/control-flow")); + let observer = Arc::new(RecordingProgress::default()); + let mut analyze = CompilerSession::new(SessionConfig::from_path(path.clone())).unwrap(); + analyze.set_progress_observer(observer.clone()); + assert!(analyze.analyze().ok); + let analyze_events = observer.0.lock().unwrap().clone(); + assert!( + analyze_events + .iter() + .any(|event| event.phase == ProgressPhase::InputResolution) + ); + assert!( + analyze_events + .iter() + .any(|event| event.phase == ProgressPhase::Parsing) + ); + assert!( + analyze_events + .iter() + .any(|event| event.phase == ProgressPhase::SemanticAnalysis) + ); + let input_index = analyze_events + .iter() + .position(|event| event.phase == ProgressPhase::InputResolution) + .unwrap(); + let parsing_index = analyze_events + .iter() + .position(|event| event.phase == ProgressPhase::Parsing) + .unwrap(); + let semantics_index = analyze_events + .iter() + .position(|event| event.phase == ProgressPhase::SemanticAnalysis) + .unwrap(); + assert!(input_index < parsing_index && parsing_index < semantics_index); + assert!( + !analyze_events + .iter() + .any(|event| event.phase == ProgressPhase::Linting) + ); + + let lint_observer = Arc::new(RecordingProgress::default()); + let mut lint = CompilerSession::new(SessionConfig::from_path(path.clone())).unwrap(); + lint.set_progress_observer(lint_observer.clone()); + assert!(lint.lint().ok); + let lint_events = lint_observer.0.lock().unwrap().clone(); + let linting = lint_events + .iter() + .find(|event| event.phase == ProgressPhase::Linting) + .expect("lint emits a linting phase"); + let lint_semantics_index = lint_events + .iter() + .position(|event| event.phase == ProgressPhase::SemanticAnalysis) + .unwrap(); + let linting_index = lint_events + .iter() + .position(|event| event.phase == ProgressPhase::Linting) + .unwrap(); + assert!(lint_semantics_index < linting_index); + assert_eq!(linting.unit, Some(ProgressUnit::Rules)); + assert!(linting.count.is_some()); + assert_ne!(analyze_events, lint_events); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + fn temp_file(name: &str, content: &str) -> PathBuf { use std::sync::atomic::{AtomicUsize, Ordering}; static COUNTER: AtomicUsize = AtomicUsize::new(0); diff --git a/docs/cli.md b/docs/cli.md index ef69fca..309eb1d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -89,16 +89,19 @@ errors produce `ERROR`, warnings produce `WARN`, and info/notice-only results produce `PASS`. Interactive terminal mode is TUI-lite by design. For text workflows selected -as `terminal`, Wright starts one delayed `working…` status on stderr after a -short threshold, so fast commands do not flicker and longer commands provide -truthful activity feedback. The status is cleared before the result is -rendered. Completed `check`, `lint`, `analyze`, and `inspect` commands print a +as `terminal`, Wright prints immediate activity feedback and then renders +truthful session phases such as input resolution, parsing, semantic analysis, +linting, emission, or conversion. A lightweight spinner starts only after a +short anti-flicker threshold; phase output is transient and is fully cleared +before the final verdict, diagnostics, report, or source artifact is rendered. +Completed `check`, `lint`, `analyze`, and `inspect` commands print a command-specific PASS/WARN/ERROR verdict and compact summary before details; diagnostics and findings include a one-line source context when the reported -provenance path is readable. This is presentation-only: no progress event, -spinner, ANSI sequence, or source context enters the driver envelope or JSON. -Plain output, redirected/piped output, `TERM=dumb`, CI, GitHub Actions, and -explicit JSON rendering remain static and deterministic. +provenance path is readable. The driver exposes typed progress events through +`ProgressObserver`; no terminal strings, spinner frames, ANSI sequence, or +source context enters the driver envelope or JSON. Plain output, +redirected/piped output, `TERM=dumb`, CI, GitHub Actions, and explicit JSON +rendering remain static and deterministic. This document is the normative contract for the compiler driver and CLI. It defines the shared driver model, the command surface, exit codes, diff --git a/docs/embedding.md b/docs/embedding.md index aa0e538..9c92114 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -9,6 +9,7 @@ safe source-edit contracts, and the transport adapters | Surface | Status | Notes | | --- | --- | --- | | `wright_driver::{CompilerSession, SessionConfig, InputSpec, SourceKind, OutputFormat, Profile}` | **stable** | One driver for compile/check/analyze/inspect/lint; `load()` is idempotent | +| `wright_driver::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}` | **stable** | Transport-neutral workflow phase events; no terminal presentation or machine-result mutation | | `wright_driver::{Envelope, CompileResult, CheckResult, AnalyzeResult, InspectResult, LintResult, Diagnostic, CompiledOutput}` | **stable** | `wright-result/v1` machine contract ([`docs/cli.md`](cli.md)) | | `wright_driver::service::{ToolService, ToolRequest, ToolResponse, Capabilities}` | **stable** | Session-aware tool queries (project/rules/symbols/references/usage/CFG/findings/lint/lintRules/callGraph/costEstimate/targetMetadata/capabilities) plus validated mutation (`validateEditTransaction`, `semanticRename`, #130) | | `wright_driver::edit::{SourceEdit, EditRange, EditTransaction, SourcePreview, EditValidation, RenameRequest, rename_symbol, validate_transaction}` | **stable** | Frontend-neutral source-edit transactions; validated through the correct native frontend/project semantics (#128); `EditTransaction::apply` applies ranges against one original source snapshot | @@ -36,6 +37,12 @@ let lint = session.lint(); // typed Envelope let compile = session.compile(); // typed Envelope ``` +Consumers that need truthful workflow progress may attach a +`ProgressObserver` before invoking a workflow and clear it before rendering or +otherwise presenting the result. Events describe real orchestration phases and +may carry bounded counts such as lint-rule count; they never contain terminal +strings, ANSI, percentages, or fabricated completion estimates. + ## Session-aware tool service `ToolService::new(&mut session)` loads the program eagerly and answers typed