diff --git a/crates/wright-analyzer/src/canonical.rs b/crates/wright-analyzer/src/canonical.rs index 24ba4733..46b21845 100644 --- a/crates/wright-analyzer/src/canonical.rs +++ b/crates/wright-analyzer/src/canonical.rs @@ -9,7 +9,7 @@ mod traversal; pub use analysis::{Finding, LintFix, RepeatedValue, RepeatedValueSpan, analyze}; pub use cfg::BLOCK_KINDS; pub(crate) use cfg::matching_end; -pub use service::{SemanticService, span_json}; +pub use service::{FindingMeta, SemanticService, span_json}; pub use symbols::*; pub(crate) use traversal::{dead_actions, unwrapped}; pub use traversal::{live_actions, subroutine_callee}; diff --git a/crates/wright-analyzer/src/canonical/service.rs b/crates/wright-analyzer/src/canonical/service.rs index c8b2d428..8b62adaf 100644 --- a/crates/wright-analyzer/src/canonical/service.rs +++ b/crates/wright-analyzer/src/canonical/service.rs @@ -11,7 +11,7 @@ use super::analysis::Finding; use super::cfg::cfg_response; use super::facts::persistent_objects; use super::symbols::{Reference, RuleId, SemanticIndex, Symbol, SymbolId}; -use crate::analysis::Boundedness; +use crate::analysis::{Boundedness, Severity}; use crate::registry::{LintConfig, SkippedRule}; use crate::service::{ErrorInfo, Origin, Request, Response}; @@ -48,10 +48,20 @@ pub struct SemanticService<'a> { registry: Arc, symbols_json: OnceLock>, references_json: OnceLock>, - findings_json: OnceLock, + findings_json: OnceLock>, lint_rules_json: OnceLock, } +/// One finding's typed identity, parallel to its serialized row: the +/// severity and code consumers must not re-parse out of JSON (#1001). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FindingMeta { + pub severity: Severity, + pub code: String, + /// The finding's span file index — the key `span.file` serializes. + pub span_file: Option, +} + impl<'a> SemanticService<'a> { pub fn new(program: &'a Program) -> Self { Self::with_origin_and_config( @@ -145,6 +155,34 @@ impl<'a> SemanticService<'a> { &self.findings } + /// The typed finding identities in [`Request::GetFindings`] order + /// (#1001), parallel to [`findings_json`](Self::findings_json): index + /// `i` describes serialized finding `i`. + pub fn findings_meta(&self) -> Vec { + self.findings + .iter() + .map(|finding| FindingMeta { + severity: finding.severity, + code: finding.code.clone(), + span_file: finding.span.map(|span| span.file.index()), + }) + .collect() + } + + /// The serialized findings in [`Request::GetFindings`] order, one JSON + /// row per finding — the payload rows [`findings_meta`](Self::findings_meta) + /// annotates (#1001). + pub fn findings_json(&self) -> &[JsonValue] { + self.findings_json + .get_or_init(|| { + self.findings + .iter() + .map(|finding| finding_json(&self.index, finding)) + .collect() + }) + .as_slice() + } + pub fn symbol_count(&self) -> usize { self.index.symbols().count() } @@ -228,7 +266,7 @@ impl<'a> SemanticService<'a> { Request::FindReferences { symbol } => { let id = SymbolId::from_index(*symbol as usize); if self.index.symbol(id).is_none() { self.error("invalid-id", format!("unknown symbol {symbol}")) } else { Response::Ok { result: self.references_json()[id.index()].clone() } } } Request::GetUsage { symbol } => { let id = SymbolId::from_index(*symbol as usize); self.index.symbol(id).map_or_else(|| self.error("invalid-id", format!("unknown symbol {symbol}")), |data| { let usage = self.index.usage(id); Response::Ok { result: json!({"id": id.index(), "kind": data.kind.as_str(), "symbol": data.name, "reads": usage.reads, "writes": usage.writes, "calls": usage.calls, "rules": usage.rules}) } }) } Request::GetCfg { rule } => cfg_response(self.program.as_ref(), *rule as usize), - Request::GetFindings => Response::Ok { result: self.findings_json.get_or_init(|| json!(self.findings.iter().map(|finding| finding_json(&self.index, finding)).collect::>())).clone() }, + Request::GetFindings => Response::Ok { result: JsonValue::Array(self.findings_json().to_vec()) }, Request::GetPersistentObjects => Response::Ok { result: json!(persistent_objects(self.program.as_ref())) }, Request::LintRules => Response::Ok { result: self.lint_rules_json.get_or_init(|| lint_rules(&self.index, &self.registry, &self.config, &self.skipped)).clone(), diff --git a/crates/wright-cli/src/essential.rs b/crates/wright-cli/src/essential.rs new file mode 100644 index 00000000..c35e4dd5 --- /dev/null +++ b/crates/wright-cli/src/essential.rs @@ -0,0 +1,73 @@ +//! The CLI-side essential-projection plumbing: wrap a workflow result's +//! serialized payload in an [`EssentialResult`] that also declares which +//! command produced it and carries the findings' typed identities, so no +//! downstream renderer re-parses the projection (#1001). + +use wright_driver::Severity; +use wright_driver::result::{AnalyzeResult, Envelope, InspectResult, LintResult}; +use wright_driver::result::{EssentialCommand, EssentialResult}; +use wright_driver::select::SelectionOutcome; + +/// Project `lint` to its essential form: the payload rows keep their +/// serialized shape while `findings_meta` carries the typed severities +/// renderers consume (#1001); `max_severity` covers the full set so the +/// verdict reflects every finding, not the projected remainder. +pub(crate) fn lint(envelope: Envelope, all: bool) -> Envelope { + envelope.map_result(|result| { + let serialized = serde_json::to_value(&result).expect("result serializes"); + let max_severity = result.findings_meta.iter().map(|meta| meta.severity).max(); + let (payload, findings_meta) = + wright_driver::essential::lint(&serialized, &result.findings_meta, all); + essential( + result.selection.clone(), + payload, + findings_meta, + max_severity, + EssentialCommand::Lint, + ) + }) +} + +/// Project `inspect` to its essential form — inspect emits no findings, +/// so the meta channel is empty (#1001). +pub(crate) fn inspect(envelope: Envelope, all: bool) -> Envelope { + envelope.map_result(|result| { + let serialized = serde_json::to_value(&result).expect("result serializes"); + let payload = wright_driver::essential::inspect(&serialized, all); + essential(None, payload, Vec::new(), None, EssentialCommand::Inspect) + }) +} + +/// Project `analyze` to its essential form: `risks_meta` supplies the +/// typed risk severities and the full-set max (#1001). +pub(crate) fn analyze(envelope: Envelope, all: bool) -> Envelope { + envelope.map_result(|result| { + let serialized = serde_json::to_value(&result).expect("result serializes"); + let max_severity = result.risks_meta.iter().map(|meta| meta.severity).max(); + let (payload, findings_meta) = + wright_driver::essential::analyze(&serialized, &result.risks_meta, all); + essential( + result.selection.clone(), + payload, + findings_meta, + max_severity, + EssentialCommand::Analyze, + ) + }) +} + +fn essential( + selection: Option, + payload: serde_json::Value, + findings_meta: Vec, + max_severity: Option, + command: EssentialCommand, +) -> EssentialResult { + EssentialResult { + result: payload, + selection, + command, + findings_meta, + max_severity, + } +} diff --git a/crates/wright-cli/src/lint.rs b/crates/wright-cli/src/lint.rs index 709a02a9..a7819bda 100644 --- a/crates/wright-cli/src/lint.rs +++ b/crates/wright-cli/src/lint.rs @@ -6,7 +6,7 @@ use std::process::ExitCode; use wright_driver::result::exit; use crate::present; -use crate::{cli, essential_envelope, fail_on, run_configured, selection_from_args}; +use crate::{cli, fail_on, run_configured, selection_from_args}; use crate::{config_from_common, present::terminal}; pub(crate) fn run(args: cli::LintArgs) -> ExitCode { @@ -36,12 +36,6 @@ pub(crate) fn run(args: cli::LintArgs) -> ExitCode { presentation.with_all(args.all), fail_on(&args.common), None, - |session| { - essential_envelope( - session.lint(), - |value| wright_driver::essential::lint(value, args.all), - |result| result.selection.clone(), - ) - }, + |session| crate::essential::lint(session.lint(), args.all), ) } diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index 91f671f7..6cbfbfa8 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -2,6 +2,7 @@ mod agent; mod agenttools; mod cli; mod completion; +mod essential; mod lint; mod mcp; mod plain; @@ -195,13 +196,7 @@ fn run_workflow(command: Command) -> ExitCode { presentation.with_all(args.all), fail_on(&args.common), None, - |session| { - essential_envelope( - session.analyze(), - |value| wright_driver::essential::analyze(value, args.all), - |result| result.selection.clone(), - ) - }, + |session| crate::essential::analyze(session.analyze(), args.all), ) } Command::Lint(args) => lint::run(args), @@ -212,13 +207,7 @@ fn run_workflow(command: Command) -> ExitCode { present::Presentation::from_common(&args.common).with_all(args.all), fail_on(&args.common), None, - |session| { - essential_envelope( - session.inspect(), - |value| wright_driver::essential::inspect(value, args.all), - |_| None, - ) - }, + |session| crate::essential::inspect(session.inspect(), args.all), ), Some(cli::InspectQuery::Rules(query)) => run_configured( "rules", @@ -367,17 +356,6 @@ fn run_workflow(command: Command) -> ExitCode { } } -pub(crate) fn essential_envelope( - envelope: wright_driver::Envelope, - form: impl FnOnce(&serde_json::Value) -> serde_json::Value, - outcome: impl FnOnce(&T) -> Option, -) -> wright_driver::Envelope { - envelope.map_result(|result| wright_driver::EssentialResult { - result: form(&serde_json::to_value(&result).expect("result serializes")), - selection: outcome(&result), - }) -} - pub(crate) fn run_configured< T: serde::Serialize + present::ResultPresentation diff --git a/crates/wright-cli/src/plain.rs b/crates/wright-cli/src/plain.rs index 12df46d3..1d4c0b37 100644 --- a/crates/wright-cli/src/plain.rs +++ b/crates/wright-cli/src/plain.rs @@ -4,17 +4,17 @@ use std::path::Path; use serde_json::Value; use wright_driver::result::{ CallGraphResult, CfgResult, CheckResult, CompileResult, ConvertResult, CostResult, Envelope, - EssentialResult, LintResult, RefsResult, RulesResult, SymbolsResult, + EssentialCommand, EssentialResult, LintResult, RefsResult, RulesResult, SymbolsResult, }; -use wright_driver::{Diagnostic, SelectionOutcome, Status}; +use wright_driver::{Diagnostic, SelectionOutcome, Severity, Status}; use crate::present::{ DETAIL_COMMANDS, RenderContext, ResultPresentation, block_flags, call_fan_counts, cfg_block_stats, cfg_edge_count, count, diagnostic_counts, diagnostic_files, - diagnostics_on_stdout, display_location_path, edge_caller, finding_files, - finding_severity_rank, findings_severity_counts, fix_status_counts, inline_location, - is_real_source_path, lookup_hint_args, notable_callers, query_items, render_node, rule_address, - rule_name, shown_diagnostics, summary_status, tree_rule, + diagnostics_on_stdout, display_location_path, edge_caller, finding_files, finding_record, + fix_status_counts, inline_location, is_real_source_path, lookup_hint_args, + meta_severity_counts, notable_callers, query_items, render_node, rule_address, rule_name, + severity_rank, shown_diagnostics, summary_status, tree_rule, }; pub(crate) fn render( @@ -339,50 +339,6 @@ fn severity_fields(error: usize, warning: usize, info: usize) -> Vec { ] } -fn finding_record(finding: &Value) -> String { - let severity = finding["severity"].as_str().unwrap_or("warning"); - let code = finding["code"].as_str().unwrap_or("finding"); - let repeated = finding.get("repeated").filter(|r| r.is_object()); - let mut items = vec![severity.to_string(), code.to_string()]; - if let Some(count) = repeated.and_then(|r| r["count"].as_u64()) { - items.push(format!("count={count}")); - } - if let Some(span) = finding.get("span").filter(|span| span.is_object()) { - if let Some(at) = at_field(span) { - items.push(at); - } - } else if let Some(location) = finding["location"].as_str() { - items.push(format!("at={location}")); - } - if let Some(repeated) = repeated { - if let Some(span_kind) = repeated["spanKind"].as_str() { - items.push(field("spanKind", span_kind)); - } - if let Some(expression) = repeated["expression"].as_str() { - items.push(field("expression", expression)); - } - } - const CARRIED: &[&str] = &[ - "code", "severity", "message", "span", "location", "fix", "repeated", - ]; - if let Some(object) = finding.as_object() { - for (key, value) in object { - if CARRIED.contains(&key.as_str()) { - continue; - } - match value { - Value::String(text) => items.push(field(key, text)), - Value::Number(_) | Value::Bool(_) => items.push(format!("{key}={value}")), - _ => {} - } - } - } - if let Some(message) = finding["message"].as_str() { - items.push(field("message", message)); - } - format!("finding: {}", items.join(" ")) -} - fn by_code_field(counts: &std::collections::BTreeMap) -> Option { if counts.is_empty() { return None; @@ -590,7 +546,7 @@ impl PlainReport for LintResult { out: &mut Vec, ) { let findings = self.findings.as_array().map_or(&[][..], Vec::as_slice); - let counts = findings_severity_counts(findings); + let counts = meta_severity_counts(&self.findings_meta); let total = self .selection .as_ref() @@ -620,10 +576,15 @@ impl PlainReport for LintResult { } out.push(format!("summary: {}", fields.join(" "))); - let mut sorted = findings.to_vec(); - sorted.sort_by_key(finding_severity_rank); - for finding in &sorted { - out.push(finding_record(finding)); + let mut sorted: Vec<(&serde_json::Value, Severity)> = findings + .iter() + .zip(&self.findings_meta) + .map(|(finding, meta)| (finding, meta.severity)) + .collect(); + debug_assert_eq!(sorted.len(), findings.len()); + sorted.sort_by_key(|(_, severity)| severity_rank(*severity)); + for (finding, severity) in &sorted { + out.push(finding_record(finding, *severity)); } if let Some(selection) = &self.selection { out.push(selected_record( @@ -677,13 +638,10 @@ impl PlainReport for EssentialResult { _ctx: &RenderContext<'_>, out: &mut Vec, ) { - let value = &self.result; - if value.get("hotspots").is_some() { - essential_analyze(self, out); - } else if value.get("program").is_some() { - essential_inspect(self, out); - } else { - essential_lint(self, out); + match self.command { + EssentialCommand::Analyze => essential_analyze(self, out), + EssentialCommand::Inspect => essential_inspect(self, out), + EssentialCommand::Lint => essential_lint(self, out), } } } @@ -734,8 +692,8 @@ fn essential_lint(result: &EssentialResult, out: &mut Vec) { fields.push(format!("affected={}", affected.len())); } out.push(format!("summary: {}", fields.join(" "))); - for finding in findings { - out.push(finding_record(finding)); + for (finding, meta) in findings.iter().zip(&result.findings_meta) { + out.push(finding_record(finding, meta.severity)); } truncation_records(&result.result, "lint", out); if let Some(selection) = &result.selection { @@ -846,8 +804,13 @@ fn essential_analyze(result: &EssentialResult, out: &mut Vec) { ], )); - for risk in value["risks"].as_array().into_iter().flatten() { - out.push(finding_record(risk)); + for (risk, meta) in value["risks"] + .as_array() + .into_iter() + .flatten() + .zip(&result.findings_meta) + { + out.push(finding_record(risk, meta.severity)); } if truncation_keys(value).contains(&"risks") { out.push(truncation_line(value, "risks", "analyze")); @@ -1287,10 +1250,17 @@ impl PlainReport for CostResult { Vec::new(), )); } - let mut findings = result["findings"].as_array().cloned().unwrap_or_default(); - findings.sort_by_key(finding_severity_rank); - for finding in &findings { - out.push(finding_record(finding)); + let mut findings: Vec<(serde_json::Value, Severity)> = result["findings"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .zip(&self.findings_meta) + .map(|(finding, meta)| (finding, meta.severity)) + .collect(); + findings.sort_by_key(|(_, severity)| severity_rank(*severity)); + for (finding, severity) in &findings { + out.push(finding_record(finding, *severity)); } truncation_records(result, "inspect cost", out); if let Some(selection) = &self.selection { @@ -1382,18 +1352,21 @@ mod tests { #[test] fn finding_record_carries_structured_fields_verbatim() { - let record = finding_record(&serde_json::json!({ - "code": "repeated-value", - "severity": "warning", - "location": "src/ana.opy:42:5", - "message": "expr evaluated repeatedly", - "evidence": "static-indicator", - "repeated": { - "expression": "distance(x, y)", - "count": 3, - "spanKind": "enclosing-loop" - } - })); + let record = finding_record( + &serde_json::json!({ + "code": "repeated-value", + "severity": "warning", + "location": "src/ana.opy:42:5", + "message": "expr evaluated repeatedly", + "evidence": "static-indicator", + "repeated": { + "expression": "distance(x, y)", + "count": 3, + "spanKind": "enclosing-loop" + } + }), + Severity::Warning, + ); assert_eq!( record, "finding: warning repeated-value count=3 at=src/ana.opy:42:5 spanKind=enclosing-loop expression=\"distance(x, y)\" evidence=static-indicator message=\"expr evaluated repeatedly\"" diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index 9de9ab47..6fe6e97a 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -21,14 +21,23 @@ use crate::cli::{ColorArg, CommonArgs, OutputFormatArg, RendererArg}; pub(crate) mod addresses; mod common; +mod findings; pub(crate) mod terminal; pub(crate) use addresses::{edge_caller, rule_address, rule_name, tree_rule}; pub(crate) use common::{ DETAIL_COMMANDS, block_flags, call_fan_counts, cfg_block_stats, cfg_edge_count, - diagnostic_counts, diagnostic_files, finding_files, findings_severity_counts, - fix_status_counts, fix_status_parts, group_runs, lookup_hint_args, normalize_finding_spans, - notable_callers, shown_diagnostics, shown_of, span_suffix, + diagnostic_counts, diagnostic_files, finding_files, finding_record, fix_status_counts, + fix_status_parts, grouped_findings, lookup_hint_args, meta_severity_counts, + normalize_finding_spans, notable_callers, severity_label, severity_rank, shown_diagnostics, + shown_of, sorted_findings, span_suffix, uniform_evidence, +}; +pub(crate) use findings::{ + finding_head, finding_location_line, finding_location_span, finding_section, lint_footer, +}; +#[cfg(test)] +use findings::{ + finding_location, renderable_group_spans, unmapped_positions, unmapped_positions_note, }; use crate::plain; @@ -338,7 +347,7 @@ pub(crate) fn render< if pres.format == OutputFormat::Json { let mut value = serde_json::to_value(envelope).expect("serializes"); if !matches!(envelope.command.as_str(), "compile" | "convert") { - wright_driver::essential::envelope(&mut value, pres.all); + wright_driver::essential::envelope(&mut value, &envelope.diagnostics, pres.all); } if envelope.command == "check" { value["schema_version"] = serde_json::Value::String("1".to_string()); @@ -370,14 +379,6 @@ pub(crate) fn diagnostics_on_stdout(envelope: &Envelope) !envelope.ok && !envelope.gated && envelope.exit != 2 && envelope.exit != 4 } -pub(crate) fn severity_rank(severity: Severity) -> u8 { - match severity { - Severity::Error => 0, - Severity::Warning => 1, - Severity::Info => 2, - } -} - fn dim(value: &str, color: bool) -> String { if color { format!("\x1b[2m{value}\x1b[0m") @@ -449,8 +450,8 @@ fn render_github( .iter() .filter_map(diagnostic_annotation) .collect(); - for finding in envelope.result.github_findings() { - if let Some(annotation) = finding_annotation(&finding) { + for (finding, severity) in envelope.result.github_findings() { + if let Some(annotation) = finding_annotation(&finding, severity) { annotations.push(annotation); } } @@ -525,8 +526,8 @@ fn diagnostic_annotation(diagnostic: &wright_driver::Diagnostic) -> Option Option { - let kind = match finding_severity(finding) { +fn finding_annotation(finding: &serde_json::Value, severity: Severity) -> Option { + let kind = match severity { Severity::Error => "error", Severity::Warning => "warning", Severity::Info => return None, @@ -620,22 +621,13 @@ impl From for SummaryStatus { } } -fn finding_severity(finding: &serde_json::Value) -> Severity { - Severity::from_label(finding.get("severity").and_then(serde_json::Value::as_str)) -} - fn max_finding_severity( - findings: &serde_json::Value, + meta: &[wright_driver::FindingMeta], selection: &Option, ) -> Option { match selection { Some(selection) => selection.max_severity, - None => findings - .as_array() - .map_or(&[][..], Vec::as_slice) - .iter() - .map(finding_severity) - .max(), + None => meta.iter().map(|meta| meta.severity).max(), } } @@ -647,7 +639,8 @@ pub(crate) trait ResultPresentation { fn artifact(&self) -> Option<&str> { None } - fn github_findings(&self) -> Vec { + /// The result's findings paired with their typed severities (#1001). + fn github_findings(&self) -> Vec<(serde_json::Value, Severity)> { Vec::new() } fn max_finding_severity(&self) -> Option { @@ -693,8 +686,7 @@ impl ResultPresentation for LintResult { selection.total, rules )); } - let counts = - findings_severity_counts(self.findings.as_array().map_or(&[][..], Vec::as_slice)); + let counts = meta_severity_counts(&self.findings_meta); let mut parts = Vec::new(); if counts[0] > 0 { parts.push(format!("{} error(s)", counts[0])); @@ -720,11 +712,18 @@ impl ResultPresentation for LintResult { } Some(metadata) } - fn github_findings(&self) -> Vec { - self.findings.as_array().cloned().unwrap_or_default() + fn github_findings(&self) -> Vec<(serde_json::Value, Severity)> { + self.findings + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .zip(&self.findings_meta) + .map(|(finding, meta)| (finding, meta.severity)) + .collect() } fn max_finding_severity(&self) -> Option { - max_finding_severity(&self.findings, &self.selection) + max_finding_severity(&self.findings_meta, &self.selection) } } @@ -770,19 +769,26 @@ fn essential_metadata(value: &serde_json::Value) -> String { } impl ResultPresentation for EssentialResult { - fn github_findings(&self) -> Vec { - ["findings", "risks"] - .iter() - .flat_map(|key| self.result[key].as_array().cloned().unwrap_or_default()) + fn github_findings(&self) -> Vec<(serde_json::Value, Severity)> { + let key = match self.command { + wright_driver::result::EssentialCommand::Analyze => "risks", + _ => "findings", + }; + self.result[key] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .zip(&self.findings_meta) + .map(|(finding, meta)| (finding, meta.severity)) .collect() } fn metadata(&self) -> Option { Some(if let Some(selection) = &self.selection { - let noun = if self.result["counts"].get("risks").is_some() { - "risk(s)" - } else { - "finding(s)" + let noun = match self.command { + wright_driver::result::EssentialCommand::Analyze => "risk(s)", + _ => "finding(s)", }; format!("{} {noun}", selection.total) } else { @@ -793,20 +799,7 @@ impl ResultPresentation for EssentialResult { if let Some(selection) = &self.selection { return selection.max_severity; } - ["findings", "risks"] - .iter() - .filter_map(|key| self.result["counts"].get(key)) - .filter_map(|group| { - [ - ("error", Severity::Error), - ("warning", Severity::Warning), - ("info", Severity::Info), - ] - .into_iter() - .find(|(label, _)| group[label].as_u64().unwrap_or(0) > 0) - .map(|(_, severity)| severity) - }) - .max() + self.max_severity } } @@ -924,14 +917,18 @@ impl ResultPresentation for CostResult { count(exact, "waitActions"), )) } - fn github_findings(&self) -> Vec { + fn github_findings(&self) -> Vec<(serde_json::Value, Severity)> { self.result["findings"] .as_array() .cloned() .unwrap_or_default() + .into_iter() + .zip(&self.findings_meta) + .map(|(finding, meta)| (finding, meta.severity)) + .collect() } fn max_finding_severity(&self) -> Option { - max_finding_severity(&self.result["findings"], &self.selection) + max_finding_severity(&self.findings_meta, &self.selection) } } @@ -1247,138 +1244,6 @@ fn persistent_object_summary(objects: &serde_json::Value) -> String { format!("{} [static]", notes.join("; ")) } -pub(crate) fn finding_severity_rank(finding: &serde_json::Value) -> u8 { - severity_rank(finding_severity(finding)) -} - -fn finding_location_span(finding: &serde_json::Value) -> Option { - if let Some(span) = finding.get("span").filter(|v| v.is_object()) { - return Some(span.clone()); - } - let mut parts = finding["location"].as_str()?.rsplitn(3, ':'); - let col = parts.next()?.parse::().ok()?; - let line = parts.next()?.parse::().ok()?; - let path = parts.next()?; - Some(serde_json::json!({"path":path,"start":{"line":line,"col":col}})) -} - -fn renderable_group_spans(group: &[serde_json::Value]) -> Vec<&serde_json::Value> { - group - .iter() - .filter_map(|finding| finding.get("span")) - .filter(|span| span.is_object()) - .filter(|span| { - span.get("path") - .and_then(serde_json::Value::as_str) - .is_some_and(is_real_source_path) - }) - .collect() -} - -fn render_finding_location( - span: &serde_json::Value, - ctx: &RenderContext<'_>, - indent: &str, - frame: bool, -) { - let Some((display, readable)) = finding_location(span, ctx.source_base) else { - return; - }; - let line = span_position(span, "start", "line").unwrap_or(0); - let col = span_position(span, "start", "col").unwrap_or(0); - println!("{indent}--> {display}:{line}:{col}"); - if !frame { - return; - } - if let Some(context) = - readable.and_then(|path| source_context(&path, line as u32, col as u32, indent)) - { - println!("{context}"); - } -} - -fn finding_location( - span: &serde_json::Value, - source_base: Option<&Path>, -) -> Option<(String, Option)> { - let path = span.get("path").and_then(serde_json::Value::as_str)?; - if !is_real_source_path(path) { - return None; - } - let reported = Path::new(path); - let candidates: Vec = if reported.is_absolute() { - vec![path.to_string()] - } else { - source_base - .map(|base| base.join(reported).display().to_string()) - .into_iter() - .chain([path.to_string()]) - .collect() - }; - for candidate in &candidates { - if Path::new(candidate).is_file() { - return Some((display_location_path(candidate), Some(candidate.clone()))); - } - } - Some((display_location_path(path), None)) -} - -fn unmapped_positions(group: &[serde_json::Value]) -> Vec<(String, u64, u64)> { - group - .iter() - .filter_map(|finding| finding.get("span")) - .filter(|span| span.is_object()) - .filter_map(|span| { - let path = span.get("path")?.as_str()?; - (!is_real_source_path(path)).then(|| { - ( - path.to_string(), - span_position(span, "start", "line").unwrap_or(0), - span_position(span, "start", "col").unwrap_or(0), - ) - }) - }) - .collect() -} - -fn unmapped_positions_note(positions: &[(String, u64, u64)]) -> Option { - let first_path = &positions.first()?.0; - let shared = positions.iter().all(|(path, ..)| path == first_path); - let mut text = String::new(); - for (index, (path, line, col)) in positions.iter().enumerate() { - if index > 0 { - text.push_str(", "); - } - if shared { - text.push_str(&format!("{line}:{col}")); - } else { - text.push_str(&format!("{path}:{line}:{col}")); - } - } - Some(if shared { - format!("at {first_path}:{text} (no source file)") - } else { - format!("at {text} (no source file)") - }) -} - -fn finding_severity_label(finding: &serde_json::Value, color: bool) -> String { - let severity = finding - .get("severity") - .and_then(serde_json::Value::as_str) - .unwrap_or("warning"); - if color { - let code = match finding_severity(finding) { - Severity::Error => "31", - Severity::Warning => "33", - Severity::Info => "36", - }; - format!("\x1b[{code}m{severity}\x1b[0m") - } else { - severity.to_string() - } -} - pub(crate) fn display_location_path(path: &str) -> String { wright_driver::input::display_path(Path::new(path)) } @@ -1550,11 +1415,28 @@ mod tests { assert_eq!(escape_workflow_data("a,b:c%\n"), "a,b:c%25%0A"); } + /// Build the typed finding metas a `GetFindings` fetch would have + /// produced for `findings` (#1001): tests construct the parallel + /// channel explicitly, the same way `SemanticService` derives it. + fn findings_meta(findings: &serde_json::Value) -> Vec { + findings + .as_array() + .into_iter() + .flatten() + .map(|finding| wright_driver::FindingMeta { + severity: Severity::from_label(finding["severity"].as_str()), + code: finding["code"].as_str().unwrap_or_default().to_string(), + span_file: None, + }) + .collect() + } + fn summary_envelope( ok: bool, diagnostics: Vec, findings: serde_json::Value, ) -> Envelope { + let findings_meta = findings_meta(&findings); Envelope { wright: wright_driver::result::VersionInfo { version: "test".to_string(), @@ -1568,6 +1450,7 @@ mod tests { gated: false, result: LintResult { findings, + findings_meta, ..LintResult::default() }, comparison: None, @@ -1720,7 +1603,7 @@ mod tests { } #[test] - fn gate_reads_the_essential_projection_counts() { + fn gate_reads_the_essential_projection_severity() { let mut envelope = Envelope { wright: wright_driver::result::VersionInfo { version: "test".to_string(), @@ -1737,6 +1620,9 @@ mod tests { "counts": {"risks": {"total": 2, "warning": 2, "error": 0, "info": 0}} }), selection: None, + command: wright_driver::result::EssentialCommand::Analyze, + findings_meta: Vec::new(), + max_severity: Some(Severity::Warning), }, comparison: None, }; @@ -1775,6 +1661,7 @@ mod tests { {"id": "a", "effectiveSeverity": "warning"}, {"id": "b", "effectiveSeverity": "error"}, ]), + findings_meta: findings_meta(&findings), findings, selection, ..LintResult::default() @@ -1815,16 +1702,10 @@ mod tests { } #[test] - fn finding_severity_rank_orders_errors_first_and_unknowns_as_warnings() { - let rank = |severity: serde_json::Value| { - finding_severity_rank(&serde_json::json!({"severity": severity})) - }; - assert_eq!(rank(serde_json::json!("error")), 0); - assert_eq!(rank(serde_json::json!("warning")), 1); - assert_eq!(rank(serde_json::json!("unexpected")), 1); - assert_eq!(rank(serde_json::json!("info")), 2); - assert_eq!(rank(serde_json::json!("notice")), 2); - assert_eq!(rank(serde_json::Value::Null), 1); + fn severity_rank_orders_errors_first() { + assert_eq!(severity_rank(Severity::Error), 0); + assert_eq!(severity_rank(Severity::Warning), 1); + assert_eq!(severity_rank(Severity::Info), 2); } #[test] @@ -1843,7 +1724,8 @@ mod tests { let pseudo = serde_json::json!({"path": "", "start": {"line": 1, "col": 1}}); assert!(finding_location(&pseudo, Some(&root)).is_none()); - let positions = unmapped_positions(&[serde_json::json!({"span": pseudo})]); + let wrapped = serde_json::json!({"span": pseudo}); + let positions = unmapped_positions(&[&wrapped]); assert_eq!( unmapped_positions_note(&positions).unwrap(), "at :1:1 (no source file)" @@ -1859,19 +1741,21 @@ mod tests { #[test] fn renderable_group_spans_counts_only_real_source_locations() { let real = |path: &str| serde_json::json!({"span": {"path": path, "start": {"line": 1, "col": 1}}}); - let group = vec![ + let group = [ real("a.ws"), real(""), serde_json::json!({"span": null}), real("b.ws"), ]; + let group: Vec<_> = group.iter().collect(); assert_eq!(renderable_group_spans(&group).len(), 2); } #[test] fn unmapped_positions_note_collapses_a_shared_pseudo_path() { let finding = |line: u64| serde_json::json!({"span": {"path": "", "start": {"line": line, "col": 1}}}); - let group = vec![finding(11), finding(14)]; + let group = [finding(11), finding(14)]; + let group: Vec<_> = group.iter().collect(); assert_eq!( unmapped_positions_note(&unmapped_positions(&group)).unwrap(), "at :11:1, 14:1 (no source file)" @@ -1882,6 +1766,7 @@ mod tests { fn unmapped_positions_note_preserves_every_selected_position() { let finding = |line: u64| serde_json::json!({"span": {"path": "", "start": {"line": line, "col": 1}}}); let group: Vec<_> = (1..=12).map(finding).collect(); + let group: Vec<_> = group.iter().collect(); let note = unmapped_positions_note(&unmapped_positions(&group)).unwrap(); for line in 1..=12 { assert!( diff --git a/crates/wright-cli/src/present/common.rs b/crates/wright-cli/src/present/common.rs index c4b904bd..05c1a637 100644 --- a/crates/wright-cli/src/present/common.rs +++ b/crates/wright-cli/src/present/common.rs @@ -4,12 +4,50 @@ use std::collections::BTreeSet; +use serde_json::Value; +use wright_driver::{FindingMeta, Severity}; + use super::addresses::edge_caller; use super::{ - array_len, display_location_path, finding_location_span, finding_severity_rank, - inline_location, is_real_source_path, severity_rank, + array_len, display_location_path, finding_location_span, inline_location, is_real_source_path, }; +/// Rank a typed severity for ordering: errors first (#1001). +pub(crate) fn severity_rank(severity: Severity) -> u8 { + match severity { + Severity::Error => 0, + Severity::Warning => 1, + Severity::Info => 2, + } +} + +/// Severity counts over a typed finding-meta list — the renderers' +/// severity input; nothing re-parses the serialized `severity` (#1001). +pub(crate) fn meta_severity_counts(meta: &[FindingMeta]) -> [usize; 3] { + let mut counts = [0usize; 3]; + for meta in meta { + counts[severity_rank(meta.severity) as usize] += 1; + } + counts +} + +/// The severity label a finding displays — colored when the presentation +/// is — from the typed severity (#1001); the serialized label is never +/// re-parsed. +pub(crate) fn severity_label(severity: Severity, color: bool) -> String { + let label = severity.as_str(); + if color { + let code = match severity { + Severity::Error => "31", + Severity::Warning => "33", + Severity::Info => "36", + }; + format!("\x1b[{code}m{label}\x1b[0m") + } else { + label.to_string() + } +} + const DIAGNOSTIC_LIMIT: usize = 20; /// Diagnostics in severity order. Unless `all` is set or the command streams @@ -48,14 +86,6 @@ pub(crate) fn diagnostic_counts(diagnostics: &[wright_driver::Diagnostic]) -> [u counts } -pub(crate) fn findings_severity_counts(findings: &[serde_json::Value]) -> [usize; 3] { - let mut counts = [0usize; 3]; - for finding in findings { - counts[finding_severity_rank(finding) as usize] += 1; - } - counts -} - pub(crate) fn diagnostic_files(diagnostics: &[wright_driver::Diagnostic]) -> BTreeSet { diagnostics .iter() @@ -211,16 +241,54 @@ pub(crate) fn normalize_finding_spans(findings: &mut serde_json::Value) { } } -/// Groups consecutive findings sharing `code` and `message` into runs. -pub(crate) fn group_runs(findings: &[serde_json::Value]) -> Vec<&[serde_json::Value]> { +pub(crate) fn shown_of(value: &serde_json::Value, key: &str) -> Option<(u64, u64)> { + value["truncation"][key].as_object().map(|note| { + ( + note["shown"].as_u64().unwrap_or(0), + note["total"].as_u64().unwrap_or(0), + ) + }) +} + +/// A result's finding rows paired with their typed severities (#1001), +/// sorted most severe first; `meta` supplies every severity, so no +/// renderer re-parses the serialized row. +pub(crate) fn sorted_findings<'f>( + findings: &'f Value, + meta: &'f [FindingMeta], +) -> Vec<(&'f Value, Severity)> { + let mut pairs: Vec<(&Value, Severity)> = findings + .as_array() + .into_iter() + .flatten() + .enumerate() + .map(|(index, row)| { + ( + row, + meta.get(index) + .map(|meta| meta.severity) + .unwrap_or(Severity::Warning), + ) + }) + .collect(); + debug_assert_eq!(pairs.len(), meta.len(), "finding meta parallels the rows"); + pairs.sort_by_key(|(_, severity)| severity_rank(*severity)); + pairs +} + +/// Consecutive severity-paired finding rows sharing `code` and `message` +/// group together, keeping each row's typed severity (#1001). +pub(crate) fn grouped_findings<'f>( + findings: &'f [(&'f Value, Severity)], +) -> Vec<&'f [(&'f Value, Severity)]> { let mut groups = Vec::new(); let mut index = 0; while index < findings.len() { - let first = &findings[index]; + let (first, _) = findings[index]; let mut end = index + 1; while end < findings.len() - && findings[end]["code"] == first["code"] - && findings[end]["message"] == first["message"] + && findings[end].0["code"] == first["code"] + && findings[end].0["message"] == first["message"] { end += 1; } @@ -230,11 +298,63 @@ pub(crate) fn group_runs(findings: &[serde_json::Value]) -> Vec<&[serde_json::Va groups } -pub(crate) fn shown_of(value: &serde_json::Value, key: &str) -> Option<(u64, u64)> { - value["truncation"][key].as_object().map(|note| { - ( - note["shown"].as_u64().unwrap_or(0), - note["total"].as_u64().unwrap_or(0), - ) - }) +/// One plain-format `finding: …` record — the severity arrives typed +/// (#1001); every other field reads the serialized row verbatim. +pub(crate) fn finding_record(finding: &Value, severity: Severity) -> String { + let severity = severity.as_str(); + let code = finding["code"].as_str().unwrap_or("finding"); + let repeated = finding.get("repeated").filter(|r| r.is_object()); + let mut items = vec![severity.to_string(), code.to_string()]; + if let Some(count) = repeated.and_then(|r| r["count"].as_u64()) { + items.push(format!("count={count}")); + } + if let Some(span) = finding.get("span").filter(|span| span.is_object()) { + if let Some(at) = + crate::present::inline_location(span).map(|location| format!("at={location}")) + { + items.push(at); + } + } else if let Some(location) = finding["location"].as_str() { + items.push(format!("at={location}")); + } + if let Some(repeated) = repeated { + if let Some(span_kind) = repeated["spanKind"].as_str() { + items.push(crate::plain::field("spanKind", span_kind)); + } + if let Some(expression) = repeated["expression"].as_str() { + items.push(crate::plain::field("expression", expression)); + } + } + const CARRIED: &[&str] = &[ + "code", "severity", "message", "span", "location", "fix", "repeated", + ]; + if let Some(object) = finding.as_object() { + for (key, value) in object { + if CARRIED.contains(&key.as_str()) { + continue; + } + match value { + Value::String(text) => items.push(crate::plain::field(key, text)), + Value::Number(_) | Value::Bool(_) => items.push(format!("{key}={value}")), + _ => {} + } + } + } + if let Some(message) = finding["message"].as_str() { + items.push(crate::plain::field("message", message)); + } + format!("finding: {}", items.join(" ")) +} + +/// The one evidence class every finding shares, when they all agree. +pub(crate) fn uniform_evidence(findings: &[&Value]) -> Option { + let mut classes = findings + .iter() + .map(|finding| finding["evidence"].as_str().unwrap_or("exact")) + .collect::>(); + if classes.len() == 1 { + classes.pop_first().map(str::to_string) + } else { + None + } } diff --git a/crates/wright-cli/src/present/findings.rs b/crates/wright-cli/src/present/findings.rs new file mode 100644 index 00000000..56a3c768 --- /dev/null +++ b/crates/wright-cli/src/present/findings.rs @@ -0,0 +1,253 @@ +//! Finding-section rendering for the terminal renderer: the grouped +//! `[severity][code]: message` blocks every finding-driven report shares. +//! Severities arrive typed beside each serialized row (#1001), so nothing +//! here re-parses the wire projection. + +use std::collections::BTreeSet; +use std::path::Path; + +use serde_json::Value; +use wright_driver::Severity; + +use super::terminal::{heading, line, note, pad, plural}; +use super::{ + RenderContext, display_location_path, grouped_findings, is_real_source_path, severity_label, + source_context, span_position, uniform_evidence, +}; + +/// The finding's `span`, synthesized from its `location` string when the +/// payload carries only the legacy spelling. +pub(crate) fn finding_location_span(finding: &Value) -> Option { + if let Some(span) = finding.get("span").filter(|v| v.is_object()) { + return Some(span.clone()); + } + let mut parts = finding["location"].as_str()?.rsplitn(3, ':'); + let col = parts.next()?.parse::().ok()?; + let line = parts.next()?.parse::().ok()?; + let path = parts.next()?; + Some(serde_json::json!({"path":path,"start":{"line":line,"col":col}})) +} + +/// The group's spans that point at real source files, in order. +pub(crate) fn renderable_group_spans<'v>(group: &[&'v Value]) -> Vec<&'v Value> { + group + .iter() + .filter_map(|finding| finding.get("span")) + .filter(|span| span.is_object()) + .filter(|span| { + span.get("path") + .and_then(Value::as_str) + .is_some_and(is_real_source_path) + }) + .collect() +} + +/// Print the `span`'s location line and optional source frame. +pub(crate) fn render_finding_location( + span: &Value, + ctx: &RenderContext<'_>, + indent: &str, + frame: bool, +) { + let Some((display, readable)) = finding_location(span, ctx.source_base) else { + return; + }; + let line = span_position(span, "start", "line").unwrap_or(0); + let col = span_position(span, "start", "col").unwrap_or(0); + println!("{indent}--> {display}:{line}:{col}"); + if !frame { + return; + } + if let Some(context) = + readable.and_then(|path| source_context(&path, line as u32, col as u32, indent)) + { + println!("{context}"); + } +} + +/// Resolve the `span`'s reported path to (display, readable) spellings +/// under `source_base` — `None` when the path names no real source file. +pub(crate) fn finding_location( + span: &Value, + source_base: Option<&Path>, +) -> Option<(String, Option)> { + let path = span.get("path").and_then(Value::as_str)?; + if !is_real_source_path(path) { + return None; + } + let reported = Path::new(path); + let candidates: Vec = if reported.is_absolute() { + vec![path.to_string()] + } else { + source_base + .map(|base| base.join(reported).display().to_string()) + .into_iter() + .chain([path.to_string()]) + .collect() + }; + for candidate in &candidates { + if Path::new(candidate).is_file() { + return Some((display_location_path(candidate), Some(candidate.clone()))); + } + } + Some((display_location_path(path), None)) +} + +/// The group's pseudo-path positions (`` etc.) that carry no real +/// source file, as (path, line, col) triples. +pub(crate) fn unmapped_positions(group: &[&Value]) -> Vec<(String, u64, u64)> { + group + .iter() + .filter_map(|finding| finding.get("span")) + .filter(|span| span.is_object()) + .filter_map(|span| { + let path = span.get("path")?.as_str()?; + (!is_real_source_path(path)).then(|| { + ( + path.to_string(), + span_position(span, "start", "line").unwrap_or(0), + span_position(span, "start", "col").unwrap_or(0), + ) + }) + }) + .collect() +} + +/// The `at :line:col …` note covering every unmapped position. +pub(crate) fn unmapped_positions_note(positions: &[(String, u64, u64)]) -> Option { + let first_path = &positions.first()?.0; + let shared = positions.iter().all(|(path, ..)| path == first_path); + let mut text = String::new(); + for (index, (path, line, col)) in positions.iter().enumerate() { + if index > 0 { + text.push_str(", "); + } + if shared { + text.push_str(&format!("{line}:{col}")); + } else { + text.push_str(&format!("{path}:{line}:{col}")); + } + } + Some(if shared { + format!("at {first_path}:{text} (no source file)") + } else { + format!("at {text} (no source file)") + }) +} + +pub(crate) fn lint_footer(ctx: &RenderContext<'_>, findings: &[&Value], skipped: usize) { + let files: BTreeSet<&str> = findings + .iter() + .filter_map(|finding| finding.get("span")) + .filter_map(|span| span.get("path")) + .filter_map(serde_json::Value::as_str) + .filter(|path| is_real_source_path(path)) + .collect(); + let mut parts = Vec::new(); + if !files.is_empty() { + parts.push(plural(files.len(), "file affected", "files affected")); + } + if skipped > 0 { + parts.push(plural( + skipped, + "rule evaluation skipped", + "rule evaluations skipped", + )); + } + if ctx.presentation.interactive { + parts.push(format!("{} ms", ctx.elapsed.as_millis())); + } + if !parts.is_empty() { + note(ctx, 1, &parts.join(" · ")); + } +} + +pub(crate) fn finding_head( + ctx: &RenderContext<'_>, + finding: &Value, + severity: Severity, + occurrences: usize, +) -> String { + let severity = severity_label(severity, ctx.presentation.color); + let code = finding["code"].as_str().unwrap_or("finding"); + let message = finding["message"].as_str().unwrap_or_default(); + if occurrences > 1 { + format!("{severity}[{code}] ({occurrences} findings): {message}") + } else { + format!("{severity}[{code}]: {message}") + } +} + +pub(crate) fn finding_location_line( + ctx: &RenderContext<'_>, + span: &Value, + depth: usize, + frame: bool, +) { + render_finding_location(span, ctx, &pad(ctx, depth), frame); +} + +pub(crate) fn finding_section( + ctx: &RenderContext<'_>, + title: &str, + findings: &[(&Value, Severity)], + withheld: Option, +) { + let rows: Vec<&Value> = findings.iter().map(|(row, _)| *row).collect(); + let evidence = uniform_evidence(&rows); + let title = match &evidence { + Some(evidence) => format!("{title} (evidence: {evidence})"), + None => title.to_string(), + }; + heading(ctx, &title); + if findings.is_empty() { + line(ctx, 1, "none"); + return; + } + let mixed = evidence.is_none(); + for group in grouped_findings(findings) { + let (first, severity) = group[0]; + line(ctx, 1, &finding_head(ctx, first, severity, group.len())); + let group_rows: Vec<&Value> = group.iter().map(|(row, _)| *row).collect(); + if group.len() == 1 { + if let Some(span) = first.get("span").filter(|span| span.is_object()) { + finding_location_line(ctx, span, 2, true); + } + } else { + for span in renderable_group_spans(&group_rows) { + finding_location_line(ctx, span, 2, false); + } + } + let mut notes = Vec::new(); + if mixed { + notes.push(format!( + "evidence: {}", + first["evidence"].as_str().unwrap_or("exact") + )); + } + if let Some(boundedness) = first.get("boundedness").and_then(Value::as_str) { + notes.push(format!("boundedness: {boundedness}")); + } + if let Some(repeated) = first.get("repeated").and_then(Value::as_u64) + && repeated > 1 + { + notes.push(format!("occurs at {repeated} sites")); + } + if let Some(unmapped) = unmapped_positions_note(&unmapped_positions(&group_rows)) { + notes.push(unmapped); + } + if !notes.is_empty() { + note(ctx, 2, &format!("= {}", notes.join(" · "))); + } + } + if let Some(withheld) = withheld.filter(|withheld| *withheld > 0) { + line( + ctx, + 1, + &format!( + "... {} withheld (--max)", + plural(withheld as usize, "finding", "findings") + ), + ); + } +} diff --git a/crates/wright-cli/src/present/terminal.rs b/crates/wright-cli/src/present/terminal.rs index 410f161c..307f3e43 100644 --- a/crates/wright-cli/src/present/terminal.rs +++ b/crates/wright-cli/src/present/terminal.rs @@ -1,21 +1,21 @@ -use std::collections::BTreeSet; - use serde_json::Value; +use wright_driver::Severity; use wright_driver::result::{ CallGraphResult, CfgResult, CheckResult, CompileResult, ConvertResult, CostResult, Envelope, - EssentialResult, LintResult, RefsResult, RulesResult, SymbolsResult, + EssentialCommand, EssentialResult, FindingMeta, LintResult, RefsResult, RulesResult, + SymbolsResult, }; use super::{ DETAIL_COMMANDS, Presentation, RenderContext, ResultPresentation, array_len, block_flags, call_fan_counts, cfg_block_stats, cfg_edge_count, count, diagnostic_counts, diagnostic_files, - diagnostics_on_stdout, dim, display_location_path, edge_caller, finding_files, - finding_location_span, finding_severity_label, finding_severity_rank, findings_severity_counts, - fix_status_counts, fix_status_parts, group_runs, is_real_source_path, location_suffix, - lookup_hint_args, normalize_finding_spans, notable_callers, persistent_object_summary, - query_items, render_diff_lines, render_finding_location, render_node, renderable_group_spans, - rule_address, rule_name, shown_diagnostics, shown_of, source_context, span_suffix, - summary_status, symbol_kind_heading, tree_rule, unmapped_positions, unmapped_positions_note, + diagnostics_on_stdout, dim, display_location_path, edge_caller, finding_head, + finding_location_line, finding_location_span, finding_section, fix_status_counts, + fix_status_parts, is_real_source_path, lint_footer, location_suffix, lookup_hint_args, + meta_severity_counts, normalize_finding_spans, notable_callers, persistent_object_summary, + query_items, render_diff_lines, render_node, rule_address, rule_name, shown_diagnostics, + shown_of, sorted_findings, source_context, span_suffix, summary_status, symbol_kind_heading, + tree_rule, uniform_evidence, }; fn tag_word(envelope: &Envelope) -> String { @@ -88,7 +88,7 @@ pub(crate) fn plural(n: usize, singular: &str, plural: &str) -> String { } } -fn pad(ctx: &RenderContext<'_>, depth: usize) -> String { +pub(crate) fn pad(ctx: &RenderContext<'_>, depth: usize) -> String { if depth == 0 { return String::new(); } @@ -229,27 +229,6 @@ fn check_footer( } } -fn lint_footer(ctx: &RenderContext<'_>, findings: &[Value], skipped: usize) { - let files = finding_files(findings); - let mut parts = Vec::new(); - if !files.is_empty() { - parts.push(plural(files.len(), "file affected", "files affected")); - } - if skipped > 0 { - parts.push(plural( - skipped, - "rule evaluation skipped", - "rule evaluations skipped", - )); - } - if ctx.presentation.interactive { - parts.push(format!("{} ms", ctx.elapsed.as_millis())); - } - if !parts.is_empty() { - note(ctx, 1, &parts.join(" · ")); - } -} - fn diagnostics( ctx: &RenderContext<'_>, envelope: &Envelope, @@ -362,96 +341,6 @@ fn elements(n: usize) -> String { plural(n, "element", "elements") } -fn finding_head(ctx: &RenderContext<'_>, finding: &Value, occurrences: usize) -> String { - let severity = finding_severity_label(finding, ctx.presentation.color); - let code = finding["code"].as_str().unwrap_or("finding"); - let message = finding["message"].as_str().unwrap_or_default(); - if occurrences > 1 { - format!("{severity}[{code}] ({occurrences} findings): {message}") - } else { - format!("{severity}[{code}]: {message}") - } -} - -fn finding_location_line(ctx: &RenderContext<'_>, span: &Value, depth: usize, frame: bool) { - render_finding_location(span, ctx, &pad(ctx, depth), frame); -} - -fn uniform_evidence(findings: &[Value]) -> Option { - let mut classes = findings - .iter() - .map(|finding| finding["evidence"].as_str().unwrap_or("exact")) - .collect::>(); - if classes.len() == 1 { - classes.pop_first().map(str::to_string) - } else { - None - } -} - -fn finding_section( - ctx: &RenderContext<'_>, - title: &str, - findings: &[Value], - withheld: Option, -) { - let evidence = uniform_evidence(findings); - let title = match &evidence { - Some(evidence) => format!("{title} (evidence: {evidence})"), - None => title.to_string(), - }; - heading(ctx, &title); - if findings.is_empty() { - line(ctx, 1, "none"); - return; - } - let mixed = evidence.is_none(); - for group in group_runs(findings) { - let first = &group[0]; - line(ctx, 1, &finding_head(ctx, first, group.len())); - if group.len() == 1 { - if let Some(span) = first.get("span").filter(|span| span.is_object()) { - finding_location_line(ctx, span, 2, true); - } - } else { - for span in renderable_group_spans(group) { - finding_location_line(ctx, span, 2, false); - } - } - let mut notes = Vec::new(); - if mixed { - notes.push(format!( - "evidence: {}", - first["evidence"].as_str().unwrap_or("exact") - )); - } - if let Some(boundedness) = first.get("boundedness").and_then(Value::as_str) { - notes.push(format!("boundedness: {boundedness}")); - } - if let Some(repeated) = first.get("repeated").and_then(Value::as_u64) - && repeated > 1 - { - notes.push(format!("occurs at {repeated} sites")); - } - if let Some(unmapped) = unmapped_positions_note(&unmapped_positions(group)) { - notes.push(unmapped); - } - if !notes.is_empty() { - note(ctx, 2, &format!("= {}", notes.join(" · "))); - } - } - if let Some(withheld) = withheld.filter(|withheld| *withheld > 0) { - line( - ctx, - 1, - &format!( - "... {} withheld (--max)", - plural(withheld as usize, "finding", "findings") - ), - ); - } -} - fn truncation_notes(ctx: &RenderContext<'_>, value: &Value) { let Some(notes) = value["truncation"].as_object() else { return; @@ -603,10 +492,7 @@ impl TerminalReport for LintResult { let mut metadata = if let Some(selection) = &self.selection { plural(selection.total, "finding", "findings") } else { - severity_counts( - findings_severity_counts(self.findings.as_array().map_or(&[][..], Vec::as_slice)), - "finding", - ) + severity_counts(meta_severity_counts(&self.findings_meta), "finding") }; metadata.push_str(&format!(" across {}", plural(rules, "rule", "rules"))); if let Some(fixes) = &self.fixes { @@ -619,14 +505,18 @@ impl TerminalReport for LintResult { Some(metadata) } fn body(&self, ctx: &RenderContext<'_>) { - let findings = sorted_findings(&self.findings); + let findings = sorted_findings(&self.findings, &self.findings_meta); finding_section( ctx, "Lint findings", &findings, withheld(self.selection.as_ref()), ); - lint_footer(ctx, &findings, array_len(&self.skipped)); + lint_footer( + ctx, + &findings.iter().map(|(row, _)| *row).collect::>(), + array_len(&self.skipped), + ); if let Some(fixes) = &self.fixes { lint_fixes(ctx, fixes); } @@ -634,12 +524,6 @@ impl TerminalReport for LintResult { } } -fn sorted_findings(findings: &Value) -> Vec { - let mut findings = findings.as_array().cloned().unwrap_or_default(); - findings.sort_by_key(finding_severity_rank); - findings -} - fn withheld(selection: Option<&wright_driver::SelectionOutcome>) -> Option { selection.and_then(|selection| (selection.withheld > 0).then_some(selection.withheld as u64)) } @@ -716,37 +600,45 @@ impl TerminalReport for EssentialResult { } fn body(&self, ctx: &RenderContext<'_>) { let value = &self.result; - if value.get("hotspots").is_some() { - analyze_body(ctx, value); - } else if let Some(program) = value.get("program").filter(|p| p.is_object()) { - program_overview(ctx, program, None); - if let Some(rules) = value["rules"].as_array() { - heading(ctx, "Rules"); - for rule in rules { - let name = match rule_address(rule) { - Some(name) => name.to_string(), - None => format!("rule {}", rule["id"]), - }; - line(ctx, 1, &name); + match self.command { + EssentialCommand::Analyze => analyze_body(ctx, value, &self.findings_meta), + EssentialCommand::Inspect => { + if let Some(program) = value.get("program").filter(|p| p.is_object()) { + program_overview(ctx, program, None); } - } - if value["next"].as_array().is_none_or(Vec::is_empty) { - heading(ctx, "Detail commands"); - for command in DETAIL_COMMANDS { - line(ctx, 1, command); + if let Some(rules) = value["rules"].as_array() { + heading(ctx, "Rules"); + for rule in rules { + let name = match rule_address(rule) { + Some(name) => name.to_string(), + None => format!("rule {}", rule["id"]), + }; + line(ctx, 1, &name); + } + } + if value["next"].as_array().is_none_or(Vec::is_empty) { + heading(ctx, "Detail commands"); + for command in DETAIL_COMMANDS { + line(ctx, 1, command); + } } } - } else { - let mut findings = value["findings"].clone(); - normalize_finding_spans(&mut findings); - let findings = sorted_findings(&findings); - finding_section( - ctx, - "Lint findings", - &findings, - withheld(self.selection.as_ref()), - ); - lint_footer(ctx, &findings, 0); + EssentialCommand::Lint => { + let mut findings = value["findings"].clone(); + normalize_finding_spans(&mut findings); + let findings = sorted_findings(&findings, &self.findings_meta); + finding_section( + ctx, + "Lint findings", + &findings, + withheld(self.selection.as_ref()), + ); + lint_footer( + ctx, + &findings.iter().map(|(row, _)| *row).collect::>(), + 0, + ); + } } truncation_notes(ctx, value); if let Some(expand) = value["expand"].as_str() { @@ -756,7 +648,7 @@ impl TerminalReport for EssentialResult { } } -fn analyze_body(ctx: &RenderContext<'_>, value: &Value) { +fn analyze_body(ctx: &RenderContext<'_>, value: &Value, risks_meta: &[FindingMeta]) { program_overview( ctx, &value["program"], @@ -880,8 +772,15 @@ fn analyze_body(ctx: &RenderContext<'_>, value: &Value) { ); } - let risks = value["risks"].as_array().map_or(&[][..], Vec::as_slice); - let evidence = uniform_evidence(risks); + let risks: Vec<(&Value, Severity)> = value["risks"] + .as_array() + .into_iter() + .flatten() + .zip(risks_meta) + .map(|(risk, meta)| (risk, meta.severity)) + .collect(); + let risk_rows: Vec<&Value> = risks.iter().map(|(row, _)| *row).collect(); + let evidence = uniform_evidence(&risk_rows); let title = match &evidence { Some(evidence) => format!("Performance and stability risks (evidence: {evidence})"), None => "Performance and stability risks".to_string(), @@ -893,8 +792,8 @@ fn analyze_body(ctx: &RenderContext<'_>, value: &Value) { "heuristic/static indicators — not measured runtime cost or behavior", ); let mixed = evidence.is_none(); - for risk in risks { - line(ctx, 2, &finding_head(ctx, risk, 1)); + for (risk, severity) in &risks { + line(ctx, 2, &finding_head(ctx, risk, *severity, 1)); if let Some(span) = finding_location_span(risk) { finding_location_line(ctx, &span, 3, false); } @@ -1321,7 +1220,7 @@ impl TerminalReport for CostResult { } } - let findings = sorted_findings(&self.result["findings"]); + let findings = sorted_findings(&self.result["findings"], &self.findings_meta); finding_section( ctx, "Static findings", diff --git a/crates/wright-driver/src/essential.rs b/crates/wright-driver/src/essential.rs index 9fd28095..c0c0f978 100644 --- a/crates/wright-driver/src/essential.rs +++ b/crates/wright-driver/src/essential.rs @@ -3,6 +3,8 @@ use std::cmp::Reverse; use serde_json::{Value, json}; use crate::Severity; +use crate::diag::Diagnostic; +use crate::result::FindingMeta; pub const DEFAULT_ITEMS: usize = 20; const DEFAULT_FINDING_BYTES: usize = 5500; @@ -13,11 +15,23 @@ fn list<'a>(value: &'a Value, key: &str) -> &'a [Value] { fn number(value: &Value, key: &str) -> u64 { value[key].as_u64().unwrap_or(0) } -fn severity(value: &Value) -> Severity { - Severity::from_label(value["severity"].as_str()) -} -fn counts(items: &[Value]) -> Value { - let count = |wanted| items.iter().filter(|item| severity(item) == wanted).count(); +/// Per-severity counts over findings whose typed identities the caller +/// supplies (#1001): `meta` parallels `items` index for index (the +/// `GetFindings`/selection order), so the projection never re-parses the +/// fields it projects. A short `meta` is a caller bug — asserted in debug, +/// unknowns count as warnings. +fn counts(items: &[Value], meta: &[FindingMeta]) -> Value { + debug_assert_eq!(items.len(), meta.len(), "finding meta parallels the rows"); + let severity = |index: usize| { + meta.get(index) + .map(|meta| meta.severity) + .unwrap_or(Severity::Warning) + }; + let count = |wanted| { + (0..items.len()) + .filter(|&index| severity(index) == wanted) + .count() + }; let mut group = json!({ "total": items.len(), "error": count(Severity::Error), @@ -48,10 +62,22 @@ fn fields(value: &Value, keys: &[&str]) -> Value { } Value::Object(result) } -fn findings(items: &[Value]) -> Vec { +/// The essential finding rows paired with their typed identities +/// (#1001): each emitted row keeps its `FindingMeta` so renderers read +/// severities from the meta, not the projection. Sorted by the typed +/// severity, most severe first — the same order the untyped projection +/// produced. +fn findings(items: &[Value], meta: &[FindingMeta]) -> Vec<(Value, FindingMeta)> { + debug_assert_eq!(items.len(), meta.len(), "finding meta parallels the rows"); let mut items = items .iter() - .map(|finding| { + .zip(meta.iter().map(Some).chain(std::iter::repeat(None))) + .map(|(finding, meta)| { + let meta = meta.cloned().unwrap_or(FindingMeta { + severity: Severity::Warning, + code: finding["code"].as_str().unwrap_or_default().to_string(), + span_file: None, + }); let mut item = fields( finding, &[ @@ -72,13 +98,81 @@ fn findings(items: &[Value]) -> Vec { number(start, "col") )); } - item + (item, meta) }) .collect::>(); - items.sort_by_key(|item| Reverse(severity(item))); + items.sort_by_key(|(_, meta)| Reverse(meta.severity)); items } +/// `cap` over a finding pair list: truncate rows and metas together and +/// record the truncation note under `key`, exactly as `cap` does on a +/// bare array. +fn cap_pairs( + output: &mut Value, + pairs: &mut Vec<(Value, FindingMeta)>, + key: &str, + limit: usize, + all: bool, +) { + let total = pairs.len(); + if all || total <= limit { + return; + } + let by_code = by_code_counts( + &pairs + .iter() + .map(|(item, _)| item.clone()) + .collect::>(), + ); + pairs.truncate(limit); + let mut note = + json!({"total":total,"shown":limit,"truncated":total-limit,"expand":"--all / all: true"}); + if let Some(by_code) = by_code { + note["byCode"] = by_code; + } + output["truncation"][key] = note; +} + +/// `fit_finding_bytes` over a finding pair list: pop rows and metas +/// together until the serialized output fits the default budget. +fn fit_finding_pairs( + output: &mut Value, + pairs: &mut Vec<(Value, FindingMeta)>, + key: &str, + total: usize, +) { + let by_code = by_code_counts( + &pairs + .iter() + .map(|(item, _)| item.clone()) + .collect::>(), + ); + while serde_json::to_vec(output) + .expect("presentation serializes") + .len() + > DEFAULT_FINDING_BYTES + { + if pairs.pop().is_none() { + break; + } + output[key] = Value::Array(pairs.iter().map(|(item, _)| item.clone()).collect()); + let shown = pairs.len(); + let note = &mut output["truncation"][key]; + if note["total"].is_null() { + note["total"] = json!(total); + } + if note["byCode"].is_null() + && let Some(by_code) = &by_code + { + note["byCode"] = by_code.clone(); + } + note["shown"] = json!(shown); + note["truncated"] = json!(note["total"].as_u64().unwrap_or(total as u64) - shown as u64); + note["expand"] = json!("--all / all: true"); + } +} + fn cap(result: &mut Value, key: &str, limit: usize, all: bool) { let Some(items) = result[key].as_array_mut() else { return; @@ -128,9 +222,15 @@ fn fit_finding_bytes(output: &mut Value, total: usize) { } } -pub fn lint(result: &Value, all: bool) -> Value { +/// The `lint` essential projection and the typed identities of the +/// emitted `findings` rows (#1001): `meta` parallels `result.findings` +/// (the `GetFindings`/selection order); the returned metas parallel the +/// emitted rows after sorting, capping, and byte-fitting, so renderers +/// stay typed end to end. +pub fn lint(result: &Value, meta: &[FindingMeta], all: bool) -> (Value, Vec) { let items = list(result, "findings"); - let mut output = json!({"counts":{"findings":counts(items)},"findings":findings(items),"expand":"lint --all / all: true; lintRules for effective configuration and rule metadata","next":crate::result::next::LINT}); + let mut pairs = findings(items, meta); + let mut output = json!({"counts":{"findings":counts(items, meta)},"expand":"lint --all / all: true; lintRules for effective configuration and rule metadata","next":crate::result::next::LINT}); for key in ["ok", "exit", "selection", "fixes", "next", "snapshot"] { if let Some(value) = result.get(key) { output[key] = value.clone(); @@ -142,11 +242,13 @@ pub fn lint(result: &Value, all: bool) -> Value { { output["inputIdentity"] = identity.clone(); } - cap(&mut output, "findings", DEFAULT_ITEMS, all); + cap_pairs(&mut output, &mut pairs, "findings", DEFAULT_ITEMS, all); + output["findings"] = Value::Array(pairs.iter().map(|(item, _)| item.clone()).collect()); if !all { - fit_finding_bytes(&mut output, items.len()); + fit_finding_pairs(&mut output, &mut pairs, "findings", items.len()); } - output + let metas = pairs.into_iter().map(|(_, meta)| meta).collect(); + (output, metas) } pub fn inspect(result: &Value, all: bool) -> Value { @@ -155,7 +257,9 @@ pub fn inspect(result: &Value, all: bool) -> Value { output } -pub fn analyze(result: &Value, all: bool) -> Value { +/// The `analyze` essential projection and the typed identities of the +/// emitted `risks` rows — the same pairing contract as [`lint`]. +pub fn analyze(result: &Value, risks_meta: &[FindingMeta], all: bool) -> (Value, Vec) { let facts = &result["facts"]; let rules = list(facts, "rules"); let risks = list(facts, "risks"); @@ -238,19 +342,18 @@ pub fn analyze(result: &Value, all: bool) -> Value { .or_default() += 1; } let object_summary = json!({"total":objects.len(),"byKind":by_kind,"withoutRetainedIdentity":objects.iter().filter(|object|object["identityRetained"]==false).count(),"visibleToAllPlayers":objects.iter().filter(|object|object["visibility"]=="all-players").count()}); - let mut output = json!({"program":result["program"],"cost":facts["cost"],"complexity":complexity,"hotspots":hotspots,"conditionTrees":trees,"risks":findings(risks),"variables":variables,"persistentObjects":object_summary,"counts":{"risks":counts(risks),"persistentObjects":list(facts,"persistentObjects").len()},"expand":"--all / all: true; inspect symbols, refs, cfg, callgraph, cost for detail","next":crate::result::next::ANALYZE}); + let mut risk_pairs = findings(risks, risks_meta); + let mut output = json!({"program":result["program"],"cost":facts["cost"],"complexity":complexity,"hotspots":hotspots,"conditionTrees":trees,"variables":variables,"persistentObjects":object_summary,"counts":{"risks":counts(risks, risks_meta),"persistentObjects":list(facts,"persistentObjects").len()},"expand":"--all / all: true; inspect symbols, refs, cfg, callgraph, cost for detail","next":crate::result::next::ANALYZE}); if let Some(selection) = result.get("selection") { output["selection"] = selection.clone(); } - for (key, limit) in [ - ("hotspots", 5), - ("conditionTrees", 3), - ("variables", 5), - ("risks", 5), - ] { + for (key, limit) in [("hotspots", 5), ("conditionTrees", 3), ("variables", 5)] { cap(&mut output, key, limit, all); } - output + cap_pairs(&mut output, &mut risk_pairs, "risks", 5, all); + output["risks"] = Value::Array(risk_pairs.iter().map(|(item, _)| item.clone()).collect()); + let metas = risk_pairs.into_iter().map(|(_, meta)| meta).collect(); + (output, metas) } /// Cap project-sized query lists after explicit selections, preserving their totals. @@ -320,10 +423,24 @@ pub fn cost(result: &Value, all: bool) -> Value { output } -/// Preserve the complete-set verdict while bounding its displayed diagnostics. -pub fn envelope(result: &mut Value, all: bool) { - if let Some(diagnostics) = result["diagnostics"].as_array_mut() { - diagnostics.sort_by_key(|item| Reverse(severity(item))); +/// Preserve the complete-set verdict while bounding its displayed +/// diagnostics. `diagnostics` carries the envelope's typed diagnostics +/// (#1001) — the projection sorts them by `Diagnostic::severity`, never by +/// re-parsing the serialized label. +pub fn envelope(result: &mut Value, diagnostics: &[Diagnostic], all: bool) { + if let Some(values) = result["diagnostics"].as_array_mut() { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by_key(|&index| { + diagnostics + .get(index) + .map(|diagnostic| Reverse(diagnostic.severity)) + .unwrap_or(Reverse(Severity::Warning)) + }); + let mut sorted = Vec::with_capacity(values.len()); + for index in order { + sorted.push(values[index].clone()); + } + *values = sorted; } cap(result, "diagnostics", DEFAULT_ITEMS, all); } @@ -331,11 +448,24 @@ pub fn envelope(result: &mut Value, all: bool) { #[cfg(test)] mod tests { use super::*; + fn meta(severity: Severity) -> FindingMeta { + FindingMeta { + severity, + code: String::new(), + span_file: None, + } + } + #[test] fn capped_findings_report_complete_counts_and_expansion() { let findings=(0..DEFAULT_ITEMS+1).map(|i|json!({"severity":if i==DEFAULT_ITEMS {"error"} else {"warning"},"code":format!("c{i}"),"message":"fix"})).collect::>(); + let meta = findings + .iter() + .map(|f| meta(Severity::from_label(f["severity"].as_str()))) + .collect::>(); let input = json!({"findings":findings,"config":{"huge":true}}); - let result = lint(&input, false); + let (result, metas) = lint(&input, &meta, false); + assert_eq!(metas.len(), DEFAULT_ITEMS); assert_eq!(result["counts"]["findings"]["error"], 1); assert_eq!(result["findings"][0]["severity"], "error"); assert_eq!( @@ -355,7 +485,10 @@ mod tests { .contains("all") ); assert_eq!( - lint(&input, true)["findings"].as_array().unwrap().len(), + lint(&input, &meta, true).0["findings"] + .as_array() + .unwrap() + .len(), DEFAULT_ITEMS + 1 ); assert!(result.get("config").is_none()); @@ -363,17 +496,19 @@ mod tests { #[test] fn diagnostics_cap_preserves_verdict_and_all_recovers_every_item() { let input = json!({"ok":false,"exit":1,"diagnostics":vec![json!({"severity":"error"});DEFAULT_ITEMS+1]}); + let diagnostics = + vec![Diagnostic::error("x", crate::Stage::Analysis, "x"); DEFAULT_ITEMS + 1]; let mut result = input.clone(); - envelope(&mut result, false); + envelope(&mut result, &diagnostics, false); assert_eq!(result["ok"], false); assert_eq!(result["exit"], 1); assert_eq!( result["truncation"]["diagnostics"]["total"], DEFAULT_ITEMS + 1 ); - envelope(&mut result, true); + envelope(&mut result, &diagnostics, true); let mut complete = input; - envelope(&mut complete, true); + envelope(&mut complete, &diagnostics, true); assert_eq!( complete["diagnostics"].as_array().unwrap().len(), DEFAULT_ITEMS + 1 @@ -413,7 +548,8 @@ mod tests { json!({"code":format!("risk-{}",id%2),"severity":"error","message":"long actionable evidence ".repeat(120)}) }) .collect::>(); - let result = lint(&json!({"findings": findings}), false); + let meta = vec![meta(Severity::Error); 6]; + let (result, _) = lint(&json!({"findings": findings}), &meta, false); let note = &result["truncation"]["findings"]; assert_eq!(note["total"], 6); assert_eq!(note["byCode"], json!({"risk-0": 3, "risk-1": 3})); @@ -474,7 +610,8 @@ mod tests { #[test] fn long_findings_fit_the_default_budget_without_changing_the_verdict() { let input = json!({"ok":false,"exit":1,"findings":(0..4).map(|id|json!({"code":format!("risk-{id}"),"severity":"error","message":"long actionable evidence ".repeat(120)})).collect::>()}); - let result = lint(&input, false); + let meta = vec![meta(Severity::Error); 4]; + let (result, _) = lint(&input, &meta, false); assert!(serde_json::to_vec(&result).unwrap().len() <= DEFAULT_FINDING_BYTES); assert_eq!(result["ok"], false); assert_eq!(result["exit"], 1); @@ -489,7 +626,7 @@ mod tests { .unwrap() .contains("all") ); - let complete = lint(&input, true); + let (complete, _) = lint(&input, &meta, true); assert_eq!(complete["findings"], input["findings"]); assert!(complete.get("truncation").is_none()); } diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index be1a77cf..40159641 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -47,8 +47,8 @@ pub use output::{CompileWrite, OutputDest, Overwrite}; pub use progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; pub use result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, - Envelope, EssentialResult, InspectResult, LintPackageResult, LintPackageRow, LintResult, - RESULT_CONTRACT, SimulateResult, + Envelope, EssentialCommand, EssentialResult, FindingMeta, InspectResult, LintPackageResult, + LintPackageRow, LintResult, RESULT_CONTRACT, SimulateResult, }; pub use select::{FindingSelection, SelectionOutcome}; pub use session::{CompilerSession, Loaded, Provenance, WORKSHOP_CLIENT_ELEMENT_LIMIT}; diff --git a/crates/wright-driver/src/result.rs b/crates/wright-driver/src/result.rs index 93bfafae..826f6e43 100644 --- a/crates/wright-driver/src/result.rs +++ b/crates/wright-driver/src/result.rs @@ -174,6 +174,10 @@ pub struct AnalyzeResult { /// Present when a finding selection was applied to `facts.risks` (#430). #[serde(skip_serializing_if = "Option::is_none")] pub selection: Option, + /// The typed identities of `facts.risks`, index-aligned (#1001): + /// in-process only — the wire rows already carry the fields. + #[serde(skip)] + pub risks_meta: Vec, /// The shared follow-up commands (#792). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub next: Vec, @@ -246,6 +250,9 @@ pub struct CostResult { /// The applied finding selection's outcome; never serialized — the wire /// member lives in `result.selection`. pub selection: Option, + /// The typed identities of `result.findings`, index-aligned (#1001): + /// in-process only — the wire rows already carry the fields. + pub findings_meta: Vec, } impl Serialize for CostResult { @@ -254,11 +261,40 @@ impl Serialize for CostResult { } } +/// One finding's typed identity, parallel to its serialized row +/// (#1001): severity and code stay typed through the driver boundary so +/// no layer re-parses them out of the wire projection. +pub use wright_analyzer::canonical::FindingMeta; + +/// Which command produced an `EssentialResult` (#1001): the projection +/// declares its producer, so renderers select their report by the tag +/// rather than by probing JSON keys. In-process only — it never +/// serializes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EssentialCommand { + /// The `lint` essential projection. + #[default] + Lint, + /// The `inspect` essential projection. + Inspect, + /// The `analyze` essential projection. + Analyze, +} + /// The essential presentation payload and complete-set selection outcome. #[derive(Debug, Clone, Default)] pub struct EssentialResult { pub result: serde_json::Value, pub selection: Option, + /// The command whose projection produced `result`. + pub command: EssentialCommand, + /// The typed identities of the emitted finding rows — `result.findings` + /// for `lint`, `result.risks` for `analyze` — index-aligned (#1001). + pub findings_meta: Vec, + /// The full finding set's highest severity before projection + /// truncation (#1001) — the verdict's severity, which the projected + /// `counts` used to answer. + pub max_severity: Option, } impl Serialize for EssentialResult { fn serialize(&self, serializer: S) -> Result { @@ -272,9 +308,13 @@ pub struct LintResult { pub program: serde_json::Value, pub rules: serde_json::Value, pub config: serde_json::Value, - /// Lint findings after selection. The verdict reports the true total via - /// `selection`, not this array's length. + /// Lint findings after selection, as serialized rows. The verdict + /// reports the true total via `selection`, not this array's length. pub findings: serde_json::Value, + /// The typed identities of `findings`, index-aligned (#1001): + /// in-process only — the wire rows already carry the fields. + #[serde(skip)] + pub findings_meta: Vec, pub skipped: serde_json::Value, /// Present when a finding selection was applied to `findings` (#430). #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/wright-driver/src/select.rs b/crates/wright-driver/src/select.rs index cd4d3ac6..8c655a59 100644 --- a/crates/wright-driver/src/select.rs +++ b/crates/wright-driver/src/select.rs @@ -120,25 +120,43 @@ impl FindingSelection { } } - /// Apply the selection to serialized findings (`code`, `severity`, - /// `span.path` fields); `file_bases` resolves `file` like - /// [`apply_diagnostics`](Self::apply_diagnostics). - pub fn apply_findings( + /// Apply the selection to serialized findings paired with their typed + /// identities (#1001): each finding's `FindingMeta` supplies severity + /// and code, so nothing re-parses them out of the serialized rows. + /// `meta` must be index-aligned with `findings` (the + /// `GetFindings`/`findings_meta` contract); the `file` selector still + /// resolves against each row's `span.path` spelling — it is part of + /// the wire projection, not the typed identity. + pub fn apply_findings_meta( &self, findings: Vec, + meta: Vec, file_bases: &[PathBuf], - ) -> (Vec, Option) { - self.apply( - findings, - |finding| { + ) -> ( + Vec, + Vec, + Option, + ) { + debug_assert_eq!( + findings.len(), + meta.len(), + "finding meta must parallel the payload" + ); + let paired: Vec<(serde_json::Value, crate::result::FindingMeta)> = + findings.into_iter().zip(meta).collect(); + let (kept, outcome) = self.apply( + paired, + |(json, meta)| { ( - Severity::from_label(finding.get("severity").and_then(|v| v.as_str())), - finding.get("code").and_then(|v| v.as_str()).unwrap_or(""), - crate::service::json_span_path(finding), + meta.severity, + meta.code.as_str(), + crate::service::json_span_path(json), ) }, file_bases, - ) + ); + let (findings, meta): (Vec<_>, Vec<_>) = kept.into_iter().unzip(); + (findings, meta, outcome) } fn apply( @@ -348,10 +366,25 @@ mod tests { serde_json::from_value(input).unwrap() } + /// The `FindingMeta` a `GetFindings` fetch would have produced for + /// `findings` — tests build the parallel typed channel explicitly + /// (#1001). + fn meta(findings: &[serde_json::Value]) -> Vec { + findings + .iter() + .map(|finding| crate::result::FindingMeta { + severity: Severity::from_label(finding["severity"].as_str()), + code: finding["code"].as_str().unwrap_or_default().to_string(), + span_file: None, + }) + .collect() + } + #[test] fn inactive_selection_returns_items_untouched() { let items = vec![finding("a", "warning", None)]; - let (kept, outcome) = FindingSelection::default().apply_findings(items.clone(), &[]); + let (kept, _, outcome) = + FindingSelection::default().apply_findings_meta(items.clone(), meta(&items), &[]); assert_eq!(kept, items); assert!(outcome.is_none()); } @@ -363,7 +396,11 @@ mod tests { finding("b", "warning", None), finding("c", "error", None), ]; - let (kept, outcome) = selection(json!({"severity": "warning"})).apply_findings(items, &[]); + let (kept, _, outcome) = selection(json!({"severity": "warning"})).apply_findings_meta( + items.clone(), + meta(&items), + &[], + ); assert_eq!( kept.iter() .map(|f| f["code"].as_str().unwrap()) @@ -383,10 +420,18 @@ mod tests { finding("a", "warning", Some("y.ws")), finding("b", "warning", Some("x.ws")), ]; - let (kept, _) = selection(json!({"rule": "a", "file": "x.ws"})).apply_findings(items, &[]); + let (kept, _, _) = selection(json!({"rule": "a", "file": "x.ws"})).apply_findings_meta( + items.clone(), + meta(&items), + &[], + ); assert_eq!(kept.len(), 1); - let (kept, _) = selection(json!({"file": "x.ws"})) - .apply_findings(vec![finding("a", "warning", None)], &[]); + let items = vec![finding("a", "warning", None)]; + let (kept, _, _) = selection(json!({"file": "x.ws"})).apply_findings_meta( + items.clone(), + meta(&items), + &[], + ); assert!(kept.is_empty()); } @@ -412,18 +457,27 @@ mod tests { }; let items = vec![finding("a", "warning", Some("sub/f.ws"))]; - let (kept, _) = selection(json!({"file": abs.to_str().unwrap()})) - .apply_findings(items, &file_bases(&input)); + let (kept, _, _) = selection(json!({"file": abs.to_str().unwrap()})).apply_findings_meta( + items.clone(), + meta(&items), + &file_bases(&input), + ); assert_eq!(kept.len(), 1); let items = vec![finding("a", "warning", Some(abs.to_str().unwrap()))]; - let (kept, _) = - selection(json!({"file": "sub/f.ws"})).apply_findings(items, &file_bases(&input)); + let (kept, _, _) = selection(json!({"file": "sub/f.ws"})).apply_findings_meta( + items.clone(), + meta(&items), + &file_bases(&input), + ); assert_eq!(kept.len(), 1); let items = vec![finding("a", "warning", Some("sub/f.ws"))]; - let (kept, _) = - selection(json!({"file": "sub/other.ws"})).apply_findings(items, &file_bases(&input)); + let (kept, _, _) = selection(json!({"file": "sub/other.ws"})).apply_findings_meta( + items.clone(), + meta(&items), + &file_bases(&input), + ); assert!(kept.is_empty()); std::fs::remove_dir_all(&root).unwrap(); @@ -437,7 +491,8 @@ mod tests { finding("c", "warning", None), finding("d", "warning", None), ]; - let (kept, outcome) = selection(json!({"max": 1})).apply_findings(items, &[]); + let (kept, _, outcome) = + selection(json!({"max": 1})).apply_findings_meta(items.clone(), meta(&items), &[]); assert_eq!(kept.len(), 1); let outcome = outcome.unwrap(); assert_eq!(outcome.total, 4); diff --git a/crates/wright-driver/src/service/cost.rs b/crates/wright-driver/src/service/cost.rs index 770e54ee..4e45f581 100644 --- a/crates/wright-driver/src/service/cost.rs +++ b/crates/wright-driver/src/service/cost.rs @@ -1,5 +1,4 @@ use serde_json::json; -use wright_analyzer::service::{Request, Response}; use super::{ToolErrorInfo, ToolResponse, ToolService}; use crate::{CompilerSession, Loaded}; @@ -11,16 +10,26 @@ impl<'a> ToolService<'a> { rule: Option<&str>, ) -> ToolResponse { match self.cost_estimate_result(selection, rule) { - Ok((result, _)) => self.ok(result), + Ok((result, _, _)) => self.ok(result), Err(error) => ToolResponse::Error { error }, } } + /// The `costEstimate` payload, its finding-selection outcome, and the + /// typed identities of the selected `result.findings` (#1001) — the + /// session keeps the meta so `CostResult` readers stay typed. pub(crate) fn cost_estimate_result( &self, selection: &crate::select::FindingSelection, rule: Option<&str>, - ) -> Result<(serde_json::Value, Option), ToolErrorInfo> { + ) -> Result< + ( + serde_json::Value, + Option, + Vec, + ), + ToolErrorInfo, + > { self.selection_check(selection)?; let loaded = &self.snapshot().loaded; if let Some(rule) = rule { @@ -32,23 +41,19 @@ impl<'a> ToolService<'a> { } return self.cost_estimate_rule(loaded, rule).map(|mut result| { result["snapshot"] = json!(self.generation); - (result, None) + (result, None, Vec::new()) }); } let locale = CompilerSession::locale_for(loaded); let emitted = workshop_rs::emitter::emit(&loaded.program, self.session.catalog(), &locale); let (program_actions, waits) = crate::session::live_action_counts(&loaded.program); let service = self.configured_semantic(); - let findings = match service.handle(&Request::GetFindings) { - Response::Ok { result } => result, - Response::Error { .. } => serde_json::json!([]), - }; - let (findings, outcome) = crate::session::select_findings( + let (findings, meta, outcome) = crate::session::select_findings( service, loaded, self.session.catalog(), selection, - findings, + crate::session::findings_with_meta(service), ); let (_, _, skipped) = super::lint_rules_split(service); let mut exact = json!({ @@ -111,7 +116,7 @@ impl<'a> ToolService<'a> { result["selection"] = serde_json::to_value(outcome).expect("selection serializes"); } result["snapshot"] = json!(self.generation); - Ok((result, outcome)) + Ok((result, outcome, meta)) } pub(crate) fn apply_cost_run( diff --git a/crates/wright-driver/src/service/mod.rs b/crates/wright-driver/src/service/mod.rs index 9b409757..0c5569eb 100644 --- a/crates/wright-driver/src/service/mod.rs +++ b/crates/wright-driver/src/service/mod.rs @@ -1,9 +1,12 @@ use serde_json::json; -use crate::diag::Diagnostic; -use crate::result::{AnalyzeResult, CheckResult, CompileResult, Envelope, InspectResult}; +use crate::diag::{Diagnostic, Severity}; +use crate::result::{ + AnalyzeResult, CheckResult, CompileResult, Envelope, FindingMeta, InspectResult, +}; #[cfg(not(target_arch = "wasm32"))] use crate::session::Provenance; +use crate::session::{findings_with_meta, select_findings}; #[cfg(not(target_arch = "wasm32"))] use crate::source_provider::document_sources; use crate::{CompilerSession, Loaded}; @@ -53,6 +56,7 @@ pub struct ToolService<'a> { generation: u64, reloads: usize, invalidated: bool, + selected_meta: Vec, } impl<'a> ToolService<'a> { @@ -78,6 +82,7 @@ impl<'a> ToolService<'a> { generation: 0, reloads: 0, invalidated: false, + selected_meta: Vec::new(), }; if let Ok(loaded) = service.session.load() { service.adopt(loaded); @@ -250,9 +255,9 @@ impl<'a> ToolService<'a> { return ToolResponse::Error { error }; } let (loaded, semantic) = self.snapshot_and_semantic(); - let result = - serde_json::to_value(self.session.analyze_loaded(loaded, &semantic, selection)) - .expect("analyze result serializes"); + let envelope = self.session.analyze_loaded(loaded, &semantic, selection); + self.selected_meta = envelope.result.risks_meta.clone(); + let result = serde_json::to_value(envelope).expect("analyze result serializes"); ToolResponse::Ok { result } } ToolRequest::Inspect { all: _ } => { @@ -304,7 +309,11 @@ impl<'a> ToolService<'a> { }, ToolRequest::Findings { selection, .. } => self.findings(selection), ToolRequest::PersistentObjects { .. } => self.persistent_objects(), - ToolRequest::Lint { selection, all: _ } => self.lint(selection), + ToolRequest::Lint { selection, all: _ } => { + let (response, meta) = self.lint(selection); + self.selected_meta = meta; + response + } ToolRequest::LintRules => self.configured_semantic().handle(&Request::LintRules), ToolRequest::CallGraph { caller, @@ -487,21 +496,16 @@ impl<'a> ToolService<'a> { if let Err(error) = self.selection_check(selection) { return ToolResponse::Error { error }; } - match self.semantic_query(Request::GetFindings) { - ToolResponse::Ok { result } => { - let (findings, outcome) = crate::session::select_findings( - self.semantic(), - &self.snapshot().loaded, - self.session.catalog(), - selection, - result, - ); - match outcome { - Some(outcome) => self.ok(json!({ "findings": findings, "selection": outcome })), - None => self.ok(serde_json::Value::Array(findings)), - } - } - other => other, + let (findings, _meta, outcome) = select_findings( + self.semantic(), + &self.snapshot().loaded, + self.session.catalog(), + selection, + findings_with_meta(self.semantic()), + ); + match outcome { + Some(outcome) => self.ok(json!({ "findings": findings, "selection": outcome })), + None => self.ok(serde_json::Value::Array(findings)), } } @@ -780,27 +784,23 @@ impl<'a> ToolService<'a> { } } - fn lint(&self, selection: &crate::select::FindingSelection) -> ToolResponse { + fn lint( + &self, + selection: &crate::select::FindingSelection, + ) -> (ToolResponse, Vec) { if let Err(error) = self.selection_check(selection) { - return ToolResponse::Error { error }; + return (ToolResponse::Error { error }, Vec::new()); } let service = self.configured_semantic(); let (rules, config, skipped) = lint_rules_split(service); - let findings = match service.handle(&Request::GetFindings) { - Response::Ok { result } => result, - Response::Error { .. } => serde_json::json!([]), - }; - let ok = !findings.as_array().is_some_and(|findings| { - findings - .iter() - .any(|finding| finding["severity"] == "error") - }); - let (findings, outcome) = crate::session::select_findings( + let (findings, meta) = findings_with_meta(service); + let ok = !meta.iter().any(|meta| meta.severity == Severity::Error); + let (findings, meta, outcome) = select_findings( service, &self.snapshot().loaded, self.session.catalog(), selection, - findings, + (findings, meta), ); let mut result = json!({ "inputIdentity": self.snapshot().loaded.input.identity, @@ -814,7 +814,7 @@ impl<'a> ToolService<'a> { if let Some(outcome) = outcome { result["selection"] = serde_json::to_value(outcome).expect("selection serializes"); } - self.ok(result) + (self.ok(result), meta) } fn project(&self) -> serde_json::Value { diff --git a/crates/wright-driver/src/service/refusal.rs b/crates/wright-driver/src/service/refusal.rs index e911d2da..7c635f58 100644 --- a/crates/wright-driver/src/service/refusal.rs +++ b/crates/wright-driver/src/service/refusal.rs @@ -35,17 +35,25 @@ impl<'a> ToolService<'a> { let ToolResponse::Ok { mut result } = response else { return response; }; + let diagnostics = + serde_json::from_value::>(result["diagnostics"].clone()) + .unwrap_or_default(); match request { - ToolRequest::Check { all } => crate::essential::envelope(&mut result, *all), + ToolRequest::Check { all } => { + crate::essential::envelope(&mut result, &diagnostics, *all) + } ToolRequest::Analyze { all, .. } => { - result["result"] = crate::essential::analyze(&result["result"], *all); - crate::essential::envelope(&mut result, *all); + result["result"] = + crate::essential::analyze(&result["result"], &self.selected_meta, *all).0; + crate::essential::envelope(&mut result, &diagnostics, *all); } ToolRequest::Inspect { all } => { result["result"] = crate::essential::inspect(&result["result"], *all); - crate::essential::envelope(&mut result, *all); + crate::essential::envelope(&mut result, &diagnostics, *all); + } + ToolRequest::Lint { all, .. } => { + result = crate::essential::lint(&result, &self.selected_meta, *all).0 } - ToolRequest::Lint { all, .. } => result = crate::essential::lint(&result, *all), ToolRequest::References { symbol, all, .. } => { let id = match self.symbol_address(symbol) { Ok(id) => id, diff --git a/crates/wright-driver/src/service/state.rs b/crates/wright-driver/src/service/state.rs index 38f15f5f..de40a30b 100644 --- a/crates/wright-driver/src/service/state.rs +++ b/crates/wright-driver/src/service/state.rs @@ -49,6 +49,7 @@ impl<'a> ToolService<'a> { generation: state.generation, reloads: state.reloads, invalidated: state.invalidated, + selected_meta: Vec::new(), }) } diff --git a/crates/wright-driver/src/session/semantic.rs b/crates/wright-driver/src/session/semantic.rs index 310cb995..a328f47a 100644 --- a/crates/wright-driver/src/session/semantic.rs +++ b/crates/wright-driver/src/session/semantic.rs @@ -10,8 +10,8 @@ use wright_analyzer::service::Request; use crate::diag::{Diagnostic, Stage}; use crate::progress::{ProgressEvent, ProgressPhase, ProgressUnit}; use crate::result::{ - AnalyzeResult, CallGraphResult, CfgResult, CostResult, Envelope, InspectResult, LintResult, - RefsResult, RulesResult, SymbolsResult, + AnalyzeResult, CallGraphResult, CfgResult, CostResult, Envelope, FindingMeta, InspectResult, + LintResult, RefsResult, RulesResult, SymbolsResult, }; use crate::service::{Address, ToolErrorInfo, ToolRequest, ToolResponse, ToolService}; @@ -27,7 +27,7 @@ fn semantic_facts( service: &SemanticService<'_>, loaded: &Loaded, catalog: &Catalog, -) -> serde_json::Value { +) -> (serde_json::Value, Vec) { let symbols = service_response(service, &Request::ListSymbols { kind: None }) .as_array() .map(|symbols| { @@ -117,15 +117,18 @@ fn semantic_facts( .unwrap_or_default(); let cost = cost_facts(&loaded.program, &element_count); - let risks = risk_facts(service); - - serde_json::json!({ - "symbols": symbols, - "rules": rules, - "cost": cost, - "risks": risks, - "persistentObjects": service_response(service, &Request::GetPersistentObjects), - }) + let (risks, risks_meta) = risk_facts(service); + + ( + serde_json::json!({ + "symbols": symbols, + "rules": rules, + "cost": cost, + "risks": risks, + "persistentObjects": service_response(service, &Request::GetPersistentObjects), + }), + risks_meta, + ) } fn cost_facts( @@ -163,7 +166,16 @@ fn cost_facts( cost } -fn risk_facts(service: &SemanticService<'_>) -> serde_json::Value { +/// The service's serialized findings paired with their typed identities +/// (#1001) — the `GetFindings` rows the wire carries, index-aligned with +/// `findings_meta`, so no consumer re-parses `severity`/`code` out of JSON. +pub(crate) fn findings_with_meta( + service: &SemanticService<'_>, +) -> (Vec, Vec) { + (service.findings_json().to_vec(), service.findings_meta()) +} + +fn risk_facts(service: &SemanticService<'_>) -> (Vec, Vec) { let lint_rules = service_response(service, &Request::LintRules); let risk_rule_ids: BTreeSet<&str> = lint_rules .get("rules") @@ -180,22 +192,12 @@ fn risk_facts(service: &SemanticService<'_>) -> serde_json::Value { }) .filter_map(|rule| rule.get("id").and_then(serde_json::Value::as_str)) .collect(); - let risks = service_response(service, &Request::GetFindings) - .as_array() - .map(|findings| { - findings - .iter() - .filter(|finding| { - finding - .get("code") - .and_then(serde_json::Value::as_str) - .is_some_and(|code| risk_rule_ids.contains(code)) - }) - .cloned() - .collect::>() - }) - .unwrap_or_default(); - serde_json::Value::Array(risks) + let (findings, meta) = findings_with_meta(service); + findings + .into_iter() + .zip(meta) + .filter(|(_, meta)| risk_rule_ids.contains(meta.code.as_str())) + .unzip() } fn program_summary(service: &SemanticService<'_>, loaded: &Loaded) -> serde_json::Value { @@ -248,7 +250,7 @@ fn analyze_result( if let serde_json::Value::Object(object) = &mut program { object.remove("findings"); } - let mut facts = semantic_facts(service, loaded, catalog); + let (mut facts, risks_meta) = semantic_facts(service, loaded, catalog); hotpath::measure_block!("analyze::resolve_span_paths", { resolve_span_paths(&mut facts, loaded) }); @@ -256,14 +258,15 @@ fn analyze_result( serde_json::Value::Array(risks) => risks, _ => Vec::new(), }; - let (risks, outcome) = - selection.apply_findings(risks, &crate::select::file_bases(&loaded.input)); + let (risks, risks_meta, outcome) = + selection.apply_findings_meta(risks, risks_meta, &crate::select::file_bases(&loaded.input)); facts["risks"] = serde_json::Value::Array(risks); AnalyzeResult { program, facts, selection: outcome, next: crate::result::next::list(crate::result::next::ANALYZE), + risks_meta, } } @@ -320,20 +323,23 @@ pub(crate) fn prepare_findings( } /// The shared findings tail after a `GetFindings` fetch: -/// [`prepare_findings`] prepares the payloads, then the surface's -/// finding selection applies. +/// [`prepare_findings`] prepares the payload rows, then the surface's +/// finding selection applies to the rows paired with their typed meta +/// (#1001) — severity and code come from `meta`, never re-parsed. pub(crate) fn select_findings( service: &SemanticService<'_>, loaded: &Loaded, catalog: &Catalog, selection: &crate::select::FindingSelection, - findings: serde_json::Value, + findings: (Vec, Vec), ) -> ( Vec, + Vec, Option, ) { - let findings = prepare_findings(findings, service, loaded, catalog); - selection.apply_findings(findings, &crate::select::file_bases(&loaded.input)) + let (findings, meta) = findings; + let findings = prepare_findings(serde_json::Value::Array(findings), service, loaded, catalog); + selection.apply_findings_meta(findings, meta, &crate::select::file_bases(&loaded.input)) } /// Move a `references` operation's selected payload into a `usage` @@ -589,11 +595,13 @@ impl CompilerSession { let selection = self.config.selection.clone(); let mut comparison = None; let mut envelope = self.service_query("cost", |service| { - let (result, outcome) = service.cost_estimate_result(&selection, rule.as_deref())?; + let (result, outcome, findings_meta) = + service.cost_estimate_result(&selection, rule.as_deref())?; comparison = service.apply_cost_run(&result)?; Ok(CostResult { result, selection: outcome, + findings_meta, }) }); envelope.comparison = comparison.map(|comparison| comparison.to_json()); @@ -690,12 +698,12 @@ impl CompilerSession { lint_rule_count, ProgressUnit::Rules, )); - let (findings, selection) = select_findings( + let (findings, findings_meta, selection) = select_findings( service, &loaded, session.catalog(), &session.config.selection, - service_response(service, &Request::GetFindings), + findings_with_meta(service), ); let (rules, config, skipped) = crate::result::split_lint_rules(&lint_rules); LintResult { @@ -704,6 +712,7 @@ impl CompilerSession { rules, config, findings: serde_json::Value::Array(findings), + findings_meta, skipped, selection, fixes: None, diff --git a/crates/wright-driver/tests/service.rs b/crates/wright-driver/tests/service.rs index 2adf41e1..b86634b2 100644 --- a/crates/wright-driver/tests/service.rs +++ b/crates/wright-driver/tests/service.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use wright_driver::edit::{EditRange, EditTransaction, SourceEdit}; +use wright_driver::essential; use wright_driver::service::{Address, ToolRequest, ToolResponse, ToolService}; use wright_driver::{ CompilerSession, FindingSelection, InputSpec, SessionConfig, Severity, SourceKind, @@ -1975,24 +1976,23 @@ fn a_changed_input_is_reloaded_and_serves_the_new_program() { .unwrap(); let mut service = ToolService::new(&mut session).unwrap(); - let names = |result: serde_json::Value| { - result["symbols"] + let symbols = |service: &mut ToolService<'_>| { + result_of( + service, + &ToolRequest::Symbols { + kind: None, + file: None, + max: None, + all: true, + }, + )["symbols"] .as_array() .unwrap() .iter() .map(|symbol| symbol["name"].as_str().unwrap().to_string()) .collect::>() }; - let before = names(result_of( - &mut service, - &ToolRequest::Symbols { - kind: None, - file: None, - max: None, - all: true, - }, - )); - assert_eq!(before, ["score", "setup"]); + assert_eq!(symbols(&mut service), ["score", "setup"]); for request in [ ToolRequest::References { @@ -2023,8 +2023,10 @@ fn a_changed_input_is_reloaded_and_serves_the_new_program() { } assert_eq!( { - let mut expected = serde_json::to_value(service.analyze()).unwrap(); - expected["result"] = wright_driver::essential::analyze(&expected["result"], true); + let envelope = service.analyze(); + let mut expected = serde_json::to_value(&envelope).unwrap(); + let meta = &envelope.result.risks_meta; + expected["result"] = essential::analyze(&expected["result"].take(), meta, true).0; expected["snapshot"] = serde_json::json!(service.snapshot_generation().unwrap()); expected }, @@ -2039,16 +2041,7 @@ fn a_changed_input_is_reloaded_and_serves_the_new_program() { std::fs::write(&input, FRESHNESS_V2).unwrap(); - let after = names(result_of( - &mut service, - &ToolRequest::Symbols { - kind: None, - file: None, - max: None, - all: true, - }, - )); - assert_eq!(after, ["points", "setup", "extra"]); + assert_eq!(symbols(&mut service), ["points", "setup", "extra"]); assert_eq!(service.reload_count(), 1); let project = result_of(&mut service, &ToolRequest::Project); diff --git a/crates/wright-language/src/lib.rs b/crates/wright-language/src/lib.rs index 56655354..ef96ec01 100644 --- a/crates/wright-language/src/lib.rs +++ b/crates/wright-language/src/lib.rs @@ -9,3 +9,7 @@ pub use service::{ SourceLocation, SourceSymbol, SourceTextEdit, }; pub use wright_driver::{OpyProviderConfig, SessionConfig}; +/// The diagnostic-severity vocabulary `SourceDiagnostic` carries — the +/// provider contract's enum, re-exported so consumers need no direct +/// `wright-lpp` dependency (#1001). +pub use wright_lpp::DiagnosticSeverity; diff --git a/crates/wright-language/src/service.rs b/crates/wright-language/src/service.rs index 1ee4d4a7..a85a09ea 100644 --- a/crates/wright-language/src/service.rs +++ b/crates/wright-language/src/service.rs @@ -5,8 +5,8 @@ use std::time::Duration; use serde::Serialize; use wright_analyzer::canonical::{PositionResolution, ReferenceKind, SemanticIndex, Symbol}; use wright_driver::opy_provider::{OPY_LANGUAGE_ID, ResolvedOpyProvider}; -use wright_driver::{CompilerSession, InputSpec, SessionConfig, SourceKind}; -use wright_lpp::{Capability, LanguageProvider, ProviderError}; +use wright_driver::{CompilerSession, InputSpec, SessionConfig, Severity, SourceKind}; +use wright_lpp::{Capability, DiagnosticSeverity, LanguageProvider, ProviderError}; use crate::document::{Document, DocumentStore, Position, Range, line_col_position, uri_to_path}; @@ -14,7 +14,10 @@ use crate::document::{Document, DocumentStore, Position, Range, line_col_positio pub struct SourceDiagnostic { pub source: String, pub range: Range, - pub severity: String, + /// The provider vocabulary's severity — `error`/`warning`/`info`/ + /// `hint`, carried typed so consumers never re-parse the label + /// (#1001); workshop-side diagnostics always report `Error`. + pub severity: DiagnosticSeverity, pub code: String, pub message: String, pub source_version: i32, @@ -162,7 +165,7 @@ impl LanguageService { vec![SourceDiagnostic { source: document.uri.clone(), range: EMPTY_RANGE, - severity: "error".to_string(), + severity: DiagnosticSeverity::Error, code: "source-provider-unavailable".to_string(), message: "source-language analysis is provider-owned; diagnostics are not yet negotiated" @@ -202,7 +205,7 @@ impl LanguageService { vec![SourceDiagnostic { source: uri.to_string(), range: EMPTY_RANGE, - severity: "error".to_string(), + severity: DiagnosticSeverity::Error, code: code.to_string(), message, source_version: version, @@ -235,7 +238,7 @@ impl LanguageService { .map(move |diagnostic| SourceDiagnostic { source: document.uri.clone(), range: diagnostic.range, - severity: lpp_severity(diagnostic.severity).to_string(), + severity: diagnostic.severity, code: diagnostic.code.clone().unwrap_or_default(), message: diagnostic.message.clone(), source_version: version, @@ -630,15 +633,6 @@ fn provider_failure_code(error: &ProviderError) -> &str { error.refusal_code().unwrap_or_else(|| error.code()) } -fn lpp_severity(severity: wright_lpp::DiagnosticSeverity) -> &'static str { - match severity { - wright_lpp::DiagnosticSeverity::Error => "error", - wright_lpp::DiagnosticSeverity::Warning => "warning", - wright_lpp::DiagnosticSeverity::Info => "info", - wright_lpp::DiagnosticSeverity::Hint => "hint", - } -} - fn source_location(location: &wright_lpp::Location) -> SourceLocation { SourceLocation { uri: location.uri.clone(), @@ -708,7 +702,11 @@ fn source_diagnostic( start: line_col_position(&document.text, span.start.line, span.start.col), end: line_col_position(&document.text, span.end.line, span.end.col), }), - severity: diagnostic.severity.as_str().to_string(), + severity: match diagnostic.severity { + Severity::Error => DiagnosticSeverity::Error, + Severity::Warning => DiagnosticSeverity::Warning, + Severity::Info => DiagnosticSeverity::Info, + }, code: diagnostic.code.clone(), message: diagnostic.message.clone(), source_version: document.version, diff --git a/crates/wright-language/tests/service.rs b/crates/wright-language/tests/service.rs index 52711ff8..9402a966 100644 --- a/crates/wright-language/tests/service.rs +++ b/crates/wright-language/tests/service.rs @@ -132,7 +132,12 @@ fn workshop_diagnostics_match_check_for_the_same_text() { ); for (diagnostic, expected) in diagnostics.iter().zip(expected.iter()) { assert_eq!(diagnostic.code, expected.code); - assert_eq!(diagnostic.severity, expected.severity.as_str()); + let expected_severity = match expected.severity.as_str() { + "error" => wright_language::DiagnosticSeverity::Error, + "warning" => wright_language::DiagnosticSeverity::Warning, + _ => wright_language::DiagnosticSeverity::Info, + }; + assert_eq!(diagnostic.severity, expected_severity); let span = expected.span.as_ref().expect("check span"); assert_eq!( diagnostic.range, @@ -157,7 +162,9 @@ fn workshop_diagnostics_follow_the_current_buffer() { service.store.change(&uri, "rule (\"x\" {\n", 1); let diagnostics = service.diagnostics(&uri); assert!( - diagnostics.iter().any(|d| d.severity == "error"), + diagnostics + .iter() + .any(|d| d.severity == wright_language::DiagnosticSeverity::Error), "the changed buffer republishes its own diagnostics: {diagnostics:?}" ); assert_eq!(diagnostics[0].document_version, 1); diff --git a/crates/wright-lsp/src/main.rs b/crates/wright-lsp/src/main.rs index cb07284c..9a61e208 100644 --- a/crates/wright-lsp/src/main.rs +++ b/crates/wright-lsp/src/main.rs @@ -709,11 +709,11 @@ fn publication_uri(service: &LanguageService, source: &str) -> Option { fn convert_diagnostic(diagnostic: wright_language::SourceDiagnostic) -> LspDiagnostic { LspDiagnostic { range: convert_range(diagnostic.range), - severity: Some(match diagnostic.severity.as_str() { - "error" => DiagnosticSeverity::ERROR, - "warning" => DiagnosticSeverity::WARNING, - "hint" => DiagnosticSeverity::HINT, - _ => DiagnosticSeverity::INFORMATION, + severity: Some(match diagnostic.severity { + wright_language::DiagnosticSeverity::Error => DiagnosticSeverity::ERROR, + wright_language::DiagnosticSeverity::Warning => DiagnosticSeverity::WARNING, + wright_language::DiagnosticSeverity::Hint => DiagnosticSeverity::HINT, + wright_language::DiagnosticSeverity::Info => DiagnosticSeverity::INFORMATION, }), code: (!diagnostic.code.is_empty()) .then_some(lsp_types::NumberOrString::String(diagnostic.code)),