diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index d3fa267..63a1604 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -24,7 +24,7 @@ pub(crate) struct Cli { pub(crate) const LONG_ABOUT: &str = "Wright compiler and Workshop tooling CLI. -Commands parse, validate, analyze, lint, inspect, compile, or reconstruct +Commands check correctness, report semantic facts, lint, inspect, compile, or reconstruct source through the typed wright-driver result envelope. `compile` and `convert` keep their source artifact stdout contracts; JSON mode prints only one wright-result/v1 envelope to stdout. @@ -61,9 +61,9 @@ pub(crate) enum Command { Compile(CompileArgs), /// Reconstruct validated Workshop input as canonical OPY or OSTW source. Convert(ConvertArgs), - /// Parse, validate, and analyze the input. + /// Check frontend, project, semantic, and validation correctness. Check(CommonArgs), - /// Parse, lower, and report semantic findings. + /// Report semantic structure, symbol usage, and CFG measurements. Analyze(CommonArgs), /// Parse, lower, and report lint findings. Lint(LintArgs), diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index 84f14cc..7d84954 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -199,6 +199,7 @@ fn run_workflow(command: Command) -> ExitCode { ConvertTargetArg::Opy => wright_driver::ConvertTarget::Opy, ConvertTargetArg::Ostw => wright_driver::ConvertTarget::Ostw, }; + let _activity = presentation.activity(); let envelope = session.convert(target); let code = envelope.exit; present::render(&envelope, presentation); @@ -244,6 +245,7 @@ fn run_command( run: fn(&mut wright_driver::CompilerSession) -> wright_driver::Envelope, presentation: present::Presentation, ) -> u8 { + let _activity = presentation.activity(); let envelope = run(session); let code = envelope.exit; present::render(&envelope, presentation); diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index 1e8e7c9..5a8cef7 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -5,6 +5,12 @@ //! Actions. JSON and source artifacts bypass every human/CI renderer. use std::io::{IsTerminal, Write}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread; +use std::time::Duration; use wright_driver::Severity; use wright_driver::config::OutputFormat; @@ -17,6 +23,59 @@ pub(crate) struct Presentation { format: OutputFormat, renderer: Renderer, color: bool, + interactive: bool, +} + +/// A deliberately small, boundary-only activity indicator for interactive +/// terminal runs. It never participates in the result contract and is never +/// created for JSON, plain, CI, or GitHub Actions rendering. +pub(crate) struct Activity { + done: Arc, + visible: Arc, + handle: Option>, +} + +impl Activity { + fn disabled() -> Self { + Self { + done: Arc::new(AtomicBool::new(true)), + visible: Arc::new(AtomicBool::new(false)), + handle: None, + } + } + + fn start() -> Self { + let done = Arc::new(AtomicBool::new(false)); + let visible = Arc::new(AtomicBool::new(false)); + let thread_done = Arc::clone(&done); + let thread_visible = Arc::clone(&visible); + 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); + } + }); + Self { + done, + visible, + handle: Some(handle), + } + } +} + +impl Drop for Activity { + fn drop(&mut self) { + self.done.store(true, Ordering::Release); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + if self.visible.load(Ordering::Acquire) { + eprint!("\r \r"); + let _ = std::io::stderr().flush(); + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -97,6 +156,17 @@ impl Presentation { format, renderer, color, + interactive: renderer == Renderer::Terminal + && environment.stdout_terminal + && !environment.term_dumb, + } + } + + pub(crate) fn activity(&self) -> Activity { + if self.format == OutputFormat::Text && self.interactive { + Activity::start() + } else { + Activity::disabled() } } } @@ -131,6 +201,10 @@ pub(crate) fn render(envelope: &Envelope, presentation: } fn render_text(envelope: &Envelope, color: bool) { + let value = serde_json::to_value(envelope).expect("envelope serializes"); + if !matches!(envelope.command.as_str(), "compile" | "convert") { + render_verdict(envelope, &value, color); + } for diagnostic in &envelope.diagnostics { render_diagnostic(diagnostic, color); } @@ -147,7 +221,7 @@ fn render_text(envelope: &Envelope, color: bool) { match envelope.command.as_str() { "compile" => render_compile(envelope), "convert" => render_convert(envelope), - "check" => println!("check: ok"), + "check" => {} "analyze" => render_analyze(envelope), "lint" => render_lint(envelope), "inspect" => render_inspect(envelope), @@ -155,6 +229,54 @@ fn render_text(envelope: &Envelope, color: bool) { } } +fn render_verdict( + envelope: &Envelope, + value: &serde_json::Value, + color: bool, +) { + let status = summary_status(envelope, value); + let label = if color { + let code = match status { + "PASS" => "32", + "WARN" => "33", + _ => "31", + }; + format!("\x1b[{code}m{status}\x1b[0m") + } else { + status.to_string() + }; + let summary = match envelope.command.as_str() { + "check" => format!( + "{label} check — {} diagnostic(s)", + envelope.diagnostics.len() + ), + "lint" => format!( + "{label} lint — {} finding(s) across {} rule(s)", + array_len(value, "/result/findings"), + array_len(value, "/result/rules"), + ), + "analyze" => format!( + "{label} analyze — {} symbol(s), {} rule measurement(s)", + array_len(value, "/result/facts/symbols"), + array_len(value, "/result/facts/rules"), + ), + "inspect" => format!( + "{label} inspect — {} rule(s), {} symbol(s)", + array_len(value, "/result/rules"), + array_len(value, "/result/symbols"), + ), + other => format!("{label} {other}"), + }; + println!("{summary}"); +} + +fn array_len(value: &serde_json::Value, pointer: &str) -> usize { + value + .pointer(pointer) + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len) +} + fn render_github(envelope: &Envelope) { for diagnostic in &envelope.diagnostics { emit_diagnostic_annotation(diagnostic); @@ -394,33 +516,68 @@ fn render_convert(envelope: &Envelope) { fn render_analyze(envelope: &Envelope) { let value = serde_json::to_value(envelope).expect("envelope serializes"); - let findings = value - .pointer("/result/findings") + let facts = value.pointer("/result/facts").cloned().unwrap_or_default(); + let symbols = facts + .get("symbols") .and_then(serde_json::Value::as_array) .cloned() .unwrap_or_default(); - println!( - "analyze: {} finding(s), {} diagnostic(s)", - findings.len(), - envelope.diagnostics.len() - ); - for finding in &findings { - let code = finding - .get("code") + let rules = facts + .get("rules") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + for symbol in &symbols { + let kind = symbol + .get("kind") .and_then(serde_json::Value::as_str) - .unwrap_or("finding"); - let severity = finding - .get("severity") + .unwrap_or("symbol"); + let name = symbol + .get("name") .and_then(serde_json::Value::as_str) - .unwrap_or("info"); - let message = finding - .get("message") + .unwrap_or(""); + let usage = symbol.get("usage").cloned().unwrap_or_default(); + println!( + " {kind} {name}: reads {}, writes {}, calls {}, rules {}", + usage + .get("reads") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + usage + .get("writes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + usage + .get("calls") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + usage + .get("rules") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + ); + } + for rule in &rules { + let name = rule + .get("name") .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - println!(" {severity}[{code}]: {message}"); - if let Some(span) = finding.get("span") { - print_span(span, " "); - } + .unwrap_or(""); + let flow = rule.get("controlFlow").cloned().unwrap_or_default(); + println!( + " rule {name}: {} blocks, {} edges, {} loop block(s), {} wait block(s)", + flow.get("blocks") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + flow.get("edges") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + flow.get("loopBlocks") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + flow.get("waitBlocks") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + ); } } @@ -431,17 +588,6 @@ fn render_lint(envelope: &Envelope) { .and_then(serde_json::Value::as_array) .cloned() .unwrap_or_default(); - let rules = value - .pointer("/result/rules") - .and_then(serde_json::Value::as_array) - .cloned() - .unwrap_or_default(); - println!( - "lint: {} finding(s) across {} rule(s), {} diagnostic(s)", - findings.len(), - rules.len(), - envelope.diagnostics.len() - ); for finding in &findings { let code = finding .get("code") @@ -609,6 +755,7 @@ fn render_diagnostic(diagnostic: &wright_driver::Diagnostic, color: bool) { ); if let Some(span) = &diagnostic.span { eprintln!(" --> {}:{}:{}", span.path, span.start.line, span.start.col); + render_source_context(&span.path, span.start.line, span.start.col, " "); } } @@ -635,6 +782,28 @@ fn print_span(span: &serde_json::Value, indent: &str) { .and_then(serde_json::Value::as_u64) .unwrap_or(0); println!("{indent}--> {path}:{line}:{col}"); + render_source_context(path, line as u32, col as u32, indent); +} + +/// Add one source line only when the provenance path resolves in the current +/// process. Structured output never calls this renderer, and an unresolved +/// path remains a normal location-only presentation. +fn render_source_context(path: &str, line: u32, col: u32, indent: &str) { + let Ok(source) = std::fs::read_to_string(path) else { + return; + }; + let Some(text) = source.lines().nth(line.saturating_sub(1) as usize) else { + return; + }; + let number_width = line.to_string().len(); + println!("{indent}| {:>number_width$} | {text}", line); + let marker_col = col.saturating_sub(1) as usize; + let prefix = text + .chars() + .take(marker_col) + .map(|ch| if ch == '\t' { '\t' } else { ' ' }) + .collect::(); + println!("{indent}| {:>number_width$} | {prefix}^", ""); } #[cfg(test)] @@ -707,6 +876,54 @@ mod tests { assert!(!never.color); } + #[test] + fn activity_is_only_enabled_for_interactive_text() { + let terminal = Presentation::resolve( + OutputFormat::Text, + RendererArg::Terminal, + ColorArg::Never, + environment(), + ); + let plain = Presentation::resolve( + OutputFormat::Text, + RendererArg::Plain, + ColorArg::Never, + environment(), + ); + let json = Presentation::resolve( + OutputFormat::Json, + RendererArg::Terminal, + ColorArg::Always, + environment(), + ); + let mut dumb_environment = environment(); + dumb_environment.term_dumb = true; + let dumb = Presentation::resolve( + OutputFormat::Text, + RendererArg::Terminal, + ColorArg::Never, + dumb_environment, + ); + assert!(terminal.activity().handle.is_some()); + assert!(plain.activity().handle.is_none()); + assert!(json.activity().handle.is_none()); + assert!(dumb.activity().handle.is_none()); + } + + #[test] + fn activity_becomes_visible_only_after_delay() { + let terminal = Presentation::resolve( + OutputFormat::Text, + RendererArg::Terminal, + ColorArg::Never, + environment(), + ); + let activity = terminal.activity(); + assert!(!activity.visible.load(Ordering::Acquire)); + thread::sleep(Duration::from_millis(180)); + assert!(activity.visible.load(Ordering::Acquire)); + } + #[test] fn workflow_command_escaping_is_split_by_context() { assert_eq!(escape_workflow_property("a,b:c%\n"), "a%2Cb%3Ac%25%0A"); diff --git a/crates/wright-cli/tests/cli.rs b/crates/wright-cli/tests/cli.rs index 6f453d2..8adad6e 100644 --- a/crates/wright-cli/tests/cli.rs +++ b/crates/wright-cli/tests/cli.rs @@ -158,7 +158,7 @@ fn check_over_clean_input_exits_zero() { let path = temp_file("basic.txt", &corpus_workshop("synthetic/basic-rule")); let output = run(&["check", path.to_str().unwrap()]); assert_eq!(output.status.code(), Some(0)); - assert!(String::from_utf8_lossy(&output.stdout).contains("check: ok")); + assert!(String::from_utf8_lossy(&output.stdout).contains("PASS check")); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -183,7 +183,7 @@ fn check_over_malformed_input_exits_one_with_structured_diagnostics() { } #[test] -fn check_reports_analysis_findings_as_diagnostics() { +fn check_excludes_configurable_lint_findings() { let path = temp_file("flow.txt", &corpus_workshop("synthetic/control-flow")); let output = run(&["check", path.to_str().unwrap(), "-f", "json"]); assert_eq!(output.status.code(), Some(0), "warnings do not fail check"); @@ -193,31 +193,30 @@ fn check_reports_analysis_findings_as_diagnostics() { .as_array() .unwrap() .iter() - .any(|diagnostic| diagnostic["code"] == "min-wait-loop") + .all(|diagnostic| diagnostic["code"] != "min-wait-loop") ); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } #[test] -fn analyze_over_workshop_input_reports_findings_with_spans() { +fn analyze_over_workshop_input_reports_semantic_facts() { let path = temp_file("flow.txt", &corpus_workshop("synthetic/control-flow")); let output = run(&["analyze", path.to_str().unwrap(), "-f", "json"]); assert!(output.status.success()); let envelope = parse_json(&output.stdout); - let findings = envelope["result"]["findings"].as_array().unwrap(); + assert!(envelope["result"]["program"]["findings"].is_null()); assert!( - findings - .iter() - .any(|finding| finding["code"] == "min-wait-loop"), - "findings: {findings:?}" + !envelope["result"]["facts"]["symbols"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + !envelope["result"]["facts"]["rules"] + .as_array() + .unwrap() + .is_empty() ); - for finding in findings { - assert!(finding["span"].is_object(), "findings carry spans"); - let path = finding["span"]["path"] - .as_str() - .expect("findings carry a resolved span path"); - assert!(!path.is_empty(), "the resolved span path is non-empty"); - } let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -245,7 +244,7 @@ fn lint_over_workshop_input_reports_findings_in_text_and_json() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("lint:"), "summary line: {stdout}"); + assert!(stdout.contains("WARN lint"), "summary line: {stdout}"); assert!(stdout.contains("min-wait-loop"), "findings: {stdout}"); assert!( stdout.contains("evidence:"), @@ -287,9 +286,8 @@ fn lint_over_workshop_input_reports_findings_in_text_and_json() { #[test] fn span_path_is_consistent_across_input_spellings() { - // The issue's repro (#102): lint resolves the same root-relative - // `span.path` for the absolute, bare-name (cwd), and dir-relative - // spellings of the same file, and `analyze` reports the identical value. + // Lint resolves the same root-relative `span.path` for the absolute, + // bare-name (cwd), and dir-relative spellings of the same file. // Each subprocess gets its own cwd, so the bare-name spelling is // exercised end-to-end exactly as in the issue. let dir = temp_dir(); @@ -348,22 +346,6 @@ fn span_path_is_consistent_across_input_spellings() { .unwrap() .to_string(); - let analyze = Command::new(wright()) - .args(["analyze", "loop.opy", "-f", "json"]) - .current_dir(dir.join("sub")) - .stdin(Stdio::null()) - .output() - .expect("wright runs"); - assert!( - analyze.status.success(), - "{}", - String::from_utf8_lossy(&analyze.stderr) - ); - let analyze_path = parse_json(&analyze.stdout)["result"]["findings"][0]["span"]["path"] - .as_str() - .unwrap() - .to_string(); - assert_eq!( absolute_path, "loop.opy", "the absolute spelling resolves to the root-relative basename" @@ -376,10 +358,6 @@ fn span_path_is_consistent_across_input_spellings() { relative_path, absolute_path, "the dir-relative spelling must agree with the absolute spelling" ); - assert_eq!( - analyze_path, absolute_path, - "analyze must report the same span.path as lint" - ); let _ = std::fs::remove_dir_all(&dir); } @@ -544,7 +522,7 @@ fn stdout_stderr_separation_holds_in_both_modes() { // Text mode: result on stdout, no stderr on success. let output = run(&["check", path.to_str().unwrap()]); assert!(output.stderr.is_empty()); - assert!(String::from_utf8_lossy(&output.stdout).contains("check: ok")); + assert!(String::from_utf8_lossy(&output.stdout).contains("PASS check")); // JSON mode: envelope on stdout only. let output = run(&["check", path.to_str().unwrap(), "-f", "json"]); assert!(output.stderr.is_empty()); diff --git a/crates/wright-cli/tests/snapshots/diagnostics_schema__check_json_matches_schema_and_snapshot.snap b/crates/wright-cli/tests/snapshots/diagnostics_schema__check_json_matches_schema_and_snapshot.snap index 908182f..db23efd 100644 --- a/crates/wright-cli/tests/snapshots/diagnostics_schema__check_json_matches_schema_and_snapshot.snap +++ b/crates/wright-cli/tests/snapshots/diagnostics_schema__check_json_matches_schema_and_snapshot.snap @@ -4,30 +4,7 @@ expression: snapshot_value --- { "command": "check", - "diagnostics": [ - { - "code": "min-wait-loop", - "message": "loop body waits at the workshop minimum rate; the loop runs at maximum frequency", - "severity": "warning", - "source": { - "kind": "workshop", - "locale": "en-us" - }, - "span": { - "end": { - "col": 13, - "line": 59 - }, - "file": 0, - "path": "", - "start": { - "col": 9, - "line": 54 - } - }, - "stage": "analysis" - } - ], + "diagnostics": [], "exit": 0, "ok": true, "result": {}, diff --git a/crates/wright-consumer/src/workflow.rs b/crates/wright-consumer/src/workflow.rs index cbffcd0..37072d8 100644 --- a/crates/wright-consumer/src/workflow.rs +++ b/crates/wright-consumer/src/workflow.rs @@ -31,8 +31,9 @@ pub fn run_consumer(input: &str) -> Result<(), String> { let analyze = session.analyze(); assert!(analyze.ok, "analyze passes"); println!( - "analyze: {} findings", - analyze.result.findings.as_array().unwrap().len() + "analyze: {} symbols, {} rules", + analyze.result.facts["symbols"].as_array().unwrap().len(), + analyze.result.facts["rules"].as_array().unwrap().len() ); // Lint through the shared session (#98): the same pipeline with diff --git a/crates/wright-driver/src/result.rs b/crates/wright-driver/src/result.rs index b30574f..f9bb5ac 100644 --- a/crates/wright-driver/src/result.rs +++ b/crates/wright-driver/src/result.rs @@ -149,11 +149,14 @@ pub struct OstwFileSummary { pub imports: Vec, } -/// The result of an `analyze` run: program summary and semantic findings. +/// The result of an `analyze` run: program summary and semantic facts. #[derive(Debug, Clone, Default, Serialize)] pub struct AnalyzeResult { pub program: serde_json::Value, - pub findings: serde_json::Value, + /// Deterministic semantic facts derived from the program structure, + /// symbol index, and control-flow graphs. This is intentionally separate + /// from the lint registry and its configurable findings. + pub facts: serde_json::Value, } /// The result of an `inspect` run: the structural/semantic program model. diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 2d3370f..25e9a99 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -390,7 +390,9 @@ impl CompilerSession { }) } - /// `check`: load, validate, and surface analysis findings as diagnostics. + /// `check`: load, validate, and surface frontend/project/semantic + /// validation diagnostics. Configurable lint findings are not part of the + /// correctness gate. pub fn check(&mut self) -> Envelope { let command = "check"; let loaded = match self.load() { @@ -414,11 +416,11 @@ impl CompilerSession { ); } self.attach_workshop_completeness(&loaded); - self.attach_analysis(&loaded); self.finish(command, CheckResult { ostw: None }) } - /// `analyze`: load and produce the semantic summary and findings. + /// `analyze`: load and produce the semantic summary and structural facts. + /// This report deliberately does not execute or expose the lint registry. pub fn analyze(&mut self) -> Envelope { let command = "analyze"; let loaded = match self.load() { @@ -440,10 +442,14 @@ impl CompilerSession { return self.finish(command, AnalyzeResult::default()); } }; - let program = service_response(&service, &Request::Program); - let mut findings = service_response(&service, &Request::GetFindings); - resolve_finding_span_paths(&mut findings, &loaded); - self.finish(command, AnalyzeResult { program, findings }) + 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, + // but an analyze report must not be a view of that lint registry. + object.remove("findings"); + } + let facts = semantic_facts(&service); + self.finish(command, AnalyzeResult { program, facts }) } /// `inspect`: load and produce the structural/semantic program model. @@ -665,42 +671,6 @@ impl CompilerSession { .map_err(|error| ir_diag("analysis-error", Stage::Analysis, error, &loaded.input)) } - /// Attach semantic-analysis findings to the diagnostic list (for `check`). - fn attach_analysis(&mut self, loaded: &Loaded) { - let Ok(service) = self.service(loaded) else { - return; - }; - let findings = service_response(&service, &Request::GetFindings); - let Some(findings) = findings.as_array() else { - return; - }; - for finding in findings { - let code = finding - .get("code") - .and_then(serde_json::Value::as_str) - .unwrap_or("finding"); - let severity = match finding.get("severity").and_then(serde_json::Value::as_str) { - Some("error") => crate::diag::Severity::Error, - Some("warning") => crate::diag::Severity::Warning, - _ => crate::diag::Severity::Info, - }; - let message = finding - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - self.diagnostics.push(Diagnostic { - code: code.to_string(), - stage: Stage::Analysis, - severity, - message, - status: None, - span: span_from_json(finding.get("span")), - source: Some(loaded.origin.clone()), - }); - } - } - /// Structural validation permits source-preserving Workshop fallbacks. /// Surface those nodes as blocking semantic diagnostics before presenting /// check/lint output as definitive. The catalog remains owned by @@ -810,23 +780,99 @@ fn service_response(service: &SemanticService<'_>, request: &Request) -> serde_j } } -/// Convert a JSON span value (from the semantic service) to a diagnostic span. -fn span_from_json(value: Option<&serde_json::Value>) -> Option { - let value = value?; - let file = value.get("file")?.as_u64()? as usize; - let start = value.get("start")?; - let end = value.get("end")?; - Some(SourceSpan { - file, - path: format!(""), - start: Position { - line: start.get("line")?.as_u64()? as u32, - col: start.get("col")?.as_u64()? as u32, - }, - end: Position { - line: end.get("line")?.as_u64()? as u32, - col: end.get("col")?.as_u64()? as u32, - }, +/// Build the initial `analyze` report from existing semantic query surfaces. +/// +/// Keeping this composition here makes the product boundary explicit: the +/// report contains symbol usage and CFG measurements, while lint rules remain +/// owned by `LintRegistry` and are only exposed by `lint`/`findings` queries. +fn semantic_facts(service: &SemanticService<'_>) -> serde_json::Value { + let symbols = service_response(service, &Request::ListSymbols { kind: None }); + let symbols = symbols + .as_array() + .map(|symbols| { + symbols + .iter() + .map(|symbol| { + let id = symbol + .get("id") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as u32; + let usage = service_response(service, &Request::GetUsage { symbol: id }); + serde_json::json!({ + "id": symbol.get("id").cloned().unwrap_or_default(), + "kind": symbol.get("kind").cloned().unwrap_or_default(), + "name": symbol.get("name").cloned().unwrap_or_default(), + "span": symbol.get("span").cloned().unwrap_or(serde_json::Value::Null), + "usage": usage, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + + let rules = service_response(service, &Request::ListRules); + let rules = rules + .as_array() + .map(|rules| { + rules + .iter() + .map(|rule| { + let id = rule + .get("id") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as u32; + let cfg = service_response(service, &Request::GetCfg { rule: id }); + let blocks = cfg + .get("blocks") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let edge_count = blocks + .iter() + .map(|block| { + block + .get("successors") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len) + }) + .sum::(); + let wait_blocks = blocks + .iter() + .filter(|block| { + block + .get("waits") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }) + .count(); + let loop_blocks = blocks + .iter() + .filter(|block| { + matches!( + block.get("kind").and_then(serde_json::Value::as_str), + Some("while" | "for") + ) + }) + .count(); + serde_json::json!({ + "id": rule.get("id").cloned().unwrap_or_default(), + "name": rule.get("name").cloned().unwrap_or_default(), + "span": rule.get("span").cloned().unwrap_or(serde_json::Value::Null), + "controlFlow": { + "blocks": blocks.len(), + "edges": edge_count, + "loopBlocks": loop_blocks, + "waitBlocks": wait_blocks, + }, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + + serde_json::json!({ + "symbols": symbols, + "rules": rules, }) } diff --git a/crates/wright-driver/tests/driver.rs b/crates/wright-driver/tests/driver.rs index d8eae96..f65f57c 100644 --- a/crates/wright-driver/tests/driver.rs +++ b/crates/wright-driver/tests/driver.rs @@ -130,7 +130,7 @@ fn workshop_compile_emits_corpus_text() { } #[test] -fn workshop_check_surfaces_analysis_findings_as_diagnostics() { +fn workshop_check_excludes_configurable_lint_findings() { let text = corpus_workshop_text("synthetic/control-flow"); let mut session = workshop_session(&text); let envelope = session.check(); @@ -139,15 +139,13 @@ fn workshop_check_surfaces_analysis_findings_as_diagnostics() { envelope .diagnostics .iter() - .any(|diagnostic| diagnostic.code == "min-wait-loop"), - "check must attach analysis findings: {:?}", - envelope.diagnostics + .all(|diagnostic| diagnostic.code != "min-wait-loop") ); assert_eq!(envelope.exit, exit::SUCCESS); } #[test] -fn workshop_analyze_reports_program_and_findings() { +fn workshop_analyze_reports_program_and_semantic_facts() { let text = corpus_workshop_text("synthetic/control-flow"); let mut session = workshop_session(&text); let envelope = session.analyze(); @@ -155,12 +153,18 @@ fn workshop_analyze_reports_program_and_findings() { assert_eq!(envelope.result.program["origin"]["kind"], "workshop"); assert_eq!(envelope.result.program["origin"]["locale"], "en-us"); assert_eq!(envelope.result.program["rules"], 2); - let findings = envelope.result.findings.as_array().unwrap(); + assert!(envelope.result.program.get("findings").is_none()); assert!( - findings - .iter() - .any(|finding| finding["code"] == "min-wait-loop"), - "findings: {findings:?}" + !envelope.result.facts["symbols"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + !envelope.result.facts["rules"] + .as_array() + .unwrap() + .is_empty() ); } @@ -205,9 +209,7 @@ fn workshop_lint_reports_structured_findings_rules_and_config() { } #[test] -fn analyze_findings_carry_the_same_span_path_as_lint() { - // The issue's core acceptance (#102): for the same source location, - // `analyze` and `lint` findings carry the identical `span.path`. +fn analyze_and_lint_have_distinct_result_surfaces() { let text = corpus_workshop_text("synthetic/control-flow"); let path = temp_file("flow.txt", &text); let mut analyze_session = CompilerSession::new(SessionConfig::from_path(path.clone())).unwrap(); @@ -216,23 +218,19 @@ fn analyze_findings_carry_the_same_span_path_as_lint() { let mut lint_session = CompilerSession::new(SessionConfig::from_path(path.clone())).unwrap(); let lint = lint_session.lint(); assert!(lint.ok, "lint: {:?}", lint.diagnostics); - let analyze_findings = analyze.result.findings.as_array().unwrap(); let lint_findings = lint.result.findings.as_array().unwrap(); assert!( - !analyze_findings.is_empty(), - "control-flow produces findings" + !lint_findings.is_empty(), + "control-flow produces lint findings" ); - assert_eq!(analyze_findings.len(), lint_findings.len()); - for (analyzed, linted) in analyze_findings.iter().zip(lint_findings) { - assert_eq!( - analyzed["span"]["path"], linted["span"]["path"], - "analyze and lint must report the same span.path for the same finding" - ); - } - assert_eq!( - analyze_findings[0]["span"]["path"], "flow.txt", - "the shared path is the root-relative file name" + assert!( + analyze.result.facts["rules"] + .as_array() + .unwrap() + .iter() + .any(|rule| rule["controlFlow"]["loopBlocks"].as_u64().unwrap_or(0) > 0) ); + assert!(analyze.result.facts.get("findings").is_none()); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -262,14 +260,9 @@ fn span_path_is_consistent_across_input_spellings() { let abs_findings = abs_lint.result.findings.as_array().unwrap(); let rel_findings = rel_lint.result.findings.as_array().unwrap(); - let analyze_findings = analyze.result.findings.as_array().unwrap(); assert!(!abs_findings.is_empty(), "loop.opy fires min-wait-loop"); assert_eq!(abs_findings.len(), rel_findings.len()); - assert_eq!(abs_findings.len(), analyze_findings.len()); - for (a, (b, c)) in abs_findings - .iter() - .zip(rel_findings.iter().zip(analyze_findings)) - { + for (a, b) in abs_findings.iter().zip(rel_findings) { assert_eq!( a["span"]["path"], "loop.opy", "the absolute spelling resolves to the root-relative basename" @@ -278,10 +271,6 @@ fn span_path_is_consistent_across_input_spellings() { a["span"]["path"], b["span"]["path"], "absolute and relative input spellings must agree" ); - assert_eq!( - a["span"]["path"], c["span"]["path"], - "lint and analyze must agree on the repro shape" - ); } let _ = std::fs::remove_dir_all(&dir); } @@ -1090,10 +1079,7 @@ fn ostw_compile_runs_the_shared_pipeline_through_the_declared_boundary() { .is_some_and(|program| !program.is_empty()), "analyze carries the shared program summary" ); - assert!( - envelope.result.findings.as_array().is_some(), - "analyze carries the shared findings list" - ); + assert!(envelope.result.facts["rules"].as_array().is_some()); } #[test] @@ -1130,12 +1116,10 @@ fn ostw_analyze_lint_inspect_run_the_shared_semantic_service() { CompilerSession::new(SessionConfig::from_path(root.join("main.ostw"))).unwrap(); let analyze = analyze_session.analyze(); assert!( - analyze - .result - .findings + analyze.result.facts["rules"] .as_array() - .is_some_and(|f| !f.is_empty()), - "analyze returns shared-analysis findings" + .is_some_and(|rules| !rules.is_empty()), + "analyze returns semantic rule measurements" ); let mut inspect_session = @@ -1216,15 +1200,7 @@ fn ostw_multi_file_provenance_survives_through_shared_workflows() { ); } - // Findings from the shared analyzer resolve to the main file (file 0 in - // the shared span convention maps to the input; imported files resolve - // through the program file registry). - let findings = envelope.result.findings.as_array().expect("findings"); - assert!(!findings.is_empty(), "shared findings present"); - for finding in findings { - if let Some(span) = finding.get("span") { - let path = span.get("path").and_then(serde_json::Value::as_str); - assert!(path.is_some(), "every finding span carries a resolved path"); - } - } + // Analyze facts are structural and intentionally do not expose lint + // findings; provenance remains on the frontend diagnostics above. + assert!(envelope.result.facts["rules"].as_array().is_some()); } diff --git a/docs/cli.md b/docs/cli.md index f6957b9..d64cae9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -88,6 +88,18 @@ runner provides that file. The summary uses the highest structured severity: 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 +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. + This document is the normative contract for the compiler driver and CLI. It defines the shared driver model, the command surface, exit codes, stdout/stderr ownership, and the `wright-result/v1` envelope that CI and @@ -102,7 +114,7 @@ CompilerSession (wright-driver) ├─ frontend: .opy bridge | native Workshop | protocol JSON ├─ validation (WIR) ├─ lowering (HIR → WIR) - ├─ analysis (SemanticService: findings, symbols, references, CFG) + ├─ analysis (SemanticService: semantic facts, symbols, references, CFG) ├─ emission (Workshop text) └─ reconstruction (WIR → canonical OPY/OSTW source, #126) ↓ @@ -122,8 +134,8 @@ result. | --- | --- | --- | | `wright compile [INPUT]` | Parse, lower, validate, emit Workshop text | the emitted artifact (or nothing with `-o`) | | `wright convert [INPUT] --target opy\|ostw` | Reconstruct validated Workshop input as canonical OPY or OSTW source | the reconstructed source | -| `wright check [INPUT]` | Parse, lower, validate, analyze | `check: ok` (or nothing on failure) | -| `wright analyze [INPUT]` | Parse, lower, analyze | findings and summary | +| `wright check [INPUT]` | Parse, lower, validate, and report correctness diagnostics | verdict and validation diagnostics | +| `wright analyze [INPUT]` | Report semantic structure, symbol usage, and CFG measurements | semantic facts and measurements | | `wright lint [INPUT]` | Parse, lower, lint; report findings | findings, rule metadata, and effective-configuration summary | | `wright inspect [INPUT]` | Parse, lower, inspect structure | rules, symbols, references summary | | `wright completion ` | Generate static completion script for bash, zsh, fish, or powershell | the generated completion script | @@ -290,17 +302,32 @@ identity; the tool/agent API exposes the same value as `inputIdentity`), } ``` -Analysis findings (`analyze`, `lint`, and the tool/agent `getFindings`/`lint` -responses) carry an `evidence` field classifying how strongly the finding is -supported (`exact`, `static-indicator`, `heuristic`, `runtime-validated`). +The three core workflows have separate contracts: + +* `check` is the correctness gate. It reports discovery, frontend, project, + semantic, lowering, and validation diagnostics. Ordinary configurable lint + findings such as `duplicate-condition` and `min-wait-loop` are not emitted + by default. +* `lint` executes the configurable `LintRegistry` and returns stable rule IDs, + severity, evidence class, boundedness where applicable, source spans, rule + metadata, and effective configuration. +* `analyze` returns semantic facts rather than lint findings. Its initial + `result.facts` report contains symbol usage (`reads`, `writes`, `calls`, and + referencing rule count) and per-rule CFG measurements (blocks, edges, loop + blocks, and wait blocks). These facts can inform future lint rules without + making analysis a view of the registry. + +Analysis findings (`lint` and the tool/agent `getFindings`/`lint` responses) +carry an `evidence` field classifying how strongly the finding is supported +(`exact`, `static-indicator`, `heuristic`, `runtime-validated`). Finding spans carry a machine-readable `path` resolved root-relative to the input include root (`--root`, defaulting to the input's directory): file 0 is the main input, and additional files in a multi-file program resolve from the program file registry. The same source location therefore reports the same -`path` across `analyze`, `lint`, and the tool/agent `Findings`/`Lint` surfaces -regardless of how the input was spelled (absolute, relative, or -cwd-relative); stdin inputs report ``. +`path` across `lint` and the tool/agent `Findings`/`Lint` surfaces regardless +of how the input was spelled (absolute, relative, or cwd-relative); stdin +inputs report ``. `while-without-wait` findings additionally carry a machine-readable `boundedness` field (`obviously-unbounded` | `statically-bounded` | `unknown`) @@ -386,17 +413,14 @@ Environment overrides (test/advanced hooks, matching `install.sh`): "command": "analyze", "ok": true, "exit": 0, - "diagnostics": [ - { - "code": "min-wait-loop", - "stage": "analysis", - "severity": "warning", - "message": "loop body waits at the workshop minimum rate; ...", - "span": { "file": 0, "path": "program.txt", "start": { "line": 28, "col": 9 }, "end": { "line": 31, "col": 13 } }, - "source": { "kind": "workshop", "locale": "en-us" } + "diagnostics": [], + "result": { + "program": { "origin": { "kind": "workshop", "locale": "en-us" }, "rules": 2 }, + "facts": { + "symbols": [{ "id": 0, "kind": "globalVariable", "name": "counter", "usage": { "reads": 1, "writes": 1, "calls": 0, "rules": 1 } }], + "rules": [{ "id": 0, "name": "loop", "controlFlow": { "blocks": 4, "edges": 4, "loopBlocks": 1, "waitBlocks": 1 } }] } - ], - "result": { "program": { "...": "..." }, "findings": [ "..."] } + } } ``` diff --git a/scripts/run-scenarios.py b/scripts/run-scenarios.py index aaf3950..fcb6309 100755 --- a/scripts/run-scenarios.py +++ b/scripts/run-scenarios.py @@ -9,7 +9,7 @@ (target/scenarios-report.json) is machine-readable and reproducible. It does not establish E-level semantic compatibility: running the Overwatch client is outside the current scope, so the recorded evidence is the compile-time -WIR/emission trace plus static findings. +WIR/emission trace plus configurable lint findings. Usage: python3 scripts/run-scenarios.py [--wright path/to/wright] """ @@ -87,8 +87,8 @@ def main() -> int: capture_output=True, text=True, ) - analyze_result = subprocess.run( - [args.wright, "analyze", str(source), "--profile", "compat", "-f", "json"], + lint_result = subprocess.run( + [args.wright, "lint", str(source), "--profile", "compat", "-f", "json"], capture_output=True, text=True, ) @@ -103,7 +103,7 @@ def main() -> int: envelope = json.loads(compile_result.stdout) text = envelope["result"]["output"]["text"] - findings = json.loads(analyze_result.stdout)["result"]["findings"] + findings = json.loads(lint_result.stdout)["result"]["findings"] entry["compileOk"] = True entry["emittedLines"] = len(text.strip().splitlines()) entry["findings"] = [