diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index 63a1604..a9f0b7e 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -24,8 +24,8 @@ pub(crate) struct Cli { pub(crate) const LONG_ABOUT: &str = "Wright compiler and Workshop tooling CLI. -Commands check correctness, report semantic facts, lint, inspect, compile, or reconstruct -source through the typed wright-driver result envelope. `compile` and `convert` +Commands check correctness, summarize semantic hotspots, lint, inspect exhaustive +facts, compile, or reconstruct source through the typed wright-driver result envelope. `compile` and `convert` keep their source artifact stdout contracts; JSON mode prints only one wright-result/v1 envelope to stdout. @@ -63,11 +63,11 @@ pub(crate) enum Command { Convert(ConvertArgs), /// Check frontend, project, semantic, and validation correctness. Check(CommonArgs), - /// Report semantic structure, symbol usage, and CFG measurements. + /// Summarize semantic structure, CFG hotspots, and cross-cutting state. Analyze(CommonArgs), /// Parse, lower, and report lint findings. Lint(LintArgs), - /// Parse, lower, and show the structural/semantic program model. + /// Parse, lower, and show exhaustive structural/semantic facts. Inspect(CommonArgs), /// Generate static shell completion from the command model. Completion(CompletionArgs), diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index 17da6e7..d56cdf3 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -259,9 +259,11 @@ fn render_verdict( array_len(value, "/result/rules"), ), "analyze" => format!( - "{} symbol(s), {} rule measurement(s)", + "{} rule(s), {} symbol(s); ranked semantic report", + value + .pointer("/result/program") + .map_or(0, |program| count(program, "rules")), array_len(value, "/result/facts/symbols"), - array_len(value, "/result/facts/rules"), ), "inspect" => format!( "{} rule(s), {} symbol(s)", @@ -527,6 +529,10 @@ fn render_convert(envelope: &Envelope) { fn render_analyze(envelope: &Envelope) { let value = serde_json::to_value(envelope).expect("envelope serializes"); + let program = value + .pointer("/result/program") + .cloned() + .unwrap_or_default(); let facts = value.pointer("/result/facts").cloned().unwrap_or_default(); let symbols = facts .get("symbols") @@ -538,61 +544,115 @@ fn render_analyze(envelope: &Envelope) { .and_then(serde_json::Value::as_array) .cloned() .unwrap_or_default(); - println!("\nAnalysis details"); - for symbol in &symbols { - let kind = symbol - .get("kind") - .and_then(serde_json::Value::as_str) - .unwrap_or("symbol"); - let name = symbol - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let usage = symbol.get("usage").cloned().unwrap_or_default(); - println!( - " {kind} {name}: reads {}, writes {}, calls {}, rules {}", - usage - .get("reads") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - usage - .get("writes") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - usage - .get("calls") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - usage - .get("rules") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - ); - } + println!("\nProgram overview"); + println!( + " {} file(s), {} rule(s), {} global variable(s), {} player variable(s), {} subroutine(s)", + count(&program, "files"), + count(&program, "rules"), + count(&program, "globalVariables"), + count(&program, "playerVariables"), + count(&program, "subroutines"), + ); + println!(" evidence: [static] parsed program inventory"); + + let mut total_blocks = 0; + let mut total_edges = 0; + let mut total_loops = 0; + let mut total_waits = 0; + let mut rule_hotspots = Vec::new(); for rule in &rules { - let name = rule - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); let flow = rule.get("controlFlow").cloned().unwrap_or_default(); - println!( - " rule {name}: {} blocks, {} edges, {} loop block(s), {} wait block(s)", - flow.get("blocks") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - flow.get("edges") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - flow.get("loopBlocks") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - flow.get("waitBlocks") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - ); + let blocks = count(&flow, "blocks"); + let edges = count(&flow, "edges"); + let loops = count(&flow, "loopBlocks"); + let waits = count(&flow, "waitBlocks"); + total_blocks += blocks; + total_edges += edges; + total_loops += loops; + total_waits += waits; + rule_hotspots.push(( + blocks + edges, + rule.get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + blocks, + edges, + loops, + waits, + )); + } + rule_hotspots.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(right.1))); + println!("\nControl-flow summary"); + println!( + " {total_blocks} blocks, {total_edges} edges, {total_loops} loop block(s), {total_waits} wait block(s)" + ); + println!(" Top rules (heuristic ranking: blocks + edges; facts are [static])"); + if rule_hotspots.is_empty() { + println!(" none"); + } else { + for (_, name, blocks, edges, loops, waits) in rule_hotspots.iter().take(5) { + println!( + " {name}: {blocks} blocks, {edges} edges, {loops} loop block(s), {waits} wait block(s)" + ); + } + } + + let mut coupled_symbols = symbols + .iter() + .filter(|symbol| { + matches!( + symbol.get("kind").and_then(serde_json::Value::as_str), + Some("globalVariable" | "playerVariable") + ) + }) + .map(|symbol| { + let usage = symbol.get("usage").cloned().unwrap_or_default(); + let rules = count(&usage, "rules"); + let reads = count(&usage, "reads"); + let writes = count(&usage, "writes"); + ( + rules, + reads + writes, + symbol + .get("kind") + .and_then(serde_json::Value::as_str) + .unwrap_or("variable"), + symbol + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + reads, + writes, + ) + }) + .collect::>(); + coupled_symbols.sort_by(|left, right| { + right + .0 + .cmp(&left.0) + .then_with(|| right.1.cmp(&left.1)) + .then_with(|| left.3.cmp(right.3)) + }); + println!("\nState and coupling"); + println!(" Top variables (heuristic ranking: rules touched, then reads + writes)"); + if coupled_symbols.is_empty() { + println!(" none"); + } else { + for (rules, _, kind, name, reads, writes) in coupled_symbols.iter().take(5) { + println!( + " {kind} {name}: {rules} rule(s), {reads} read(s), {writes} write(s) [static]" + ); + } } } +fn count(value: &serde_json::Value, key: &str) -> usize { + value + .get(key) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) as usize +} + fn render_lint(envelope: &Envelope) { let value = serde_json::to_value(envelope).expect("envelope serializes"); let findings = value diff --git a/crates/wright-cli/tests/cli.rs b/crates/wright-cli/tests/cli.rs index 317a2c7..c234253 100644 --- a/crates/wright-cli/tests/cli.rs +++ b/crates/wright-cli/tests/cli.rs @@ -168,7 +168,7 @@ fn terminal_renderer_uses_command_specific_hierarchy() { for (command, heading, detail) in [ ("check", "PASS check", "diagnostic(s)"), ("lint", "WARN lint", "Lint findings"), - ("analyze", "PASS analyze", "Analysis details"), + ("analyze", "PASS analyze", "Program overview"), ("inspect", "PASS inspect", "Program structure"), ] { let output = run(&[ @@ -187,6 +187,34 @@ fn terminal_renderer_uses_command_specific_hierarchy() { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } +#[test] +fn analyze_real_project_report_is_bounded_and_ranked() { + let path = temp_file( + "pixelart.txt", + &corpus_workshop("real-world/overpy-pixelart"), + ); + let output = run(&[ + "analyze", + path.to_str().unwrap(), + "--renderer", + "terminal", + "--color", + "never", + ]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Program overview")); + assert!(stdout.contains("Control-flow summary")); + assert!(stdout.contains("Top rules (heuristic ranking")); + assert!(stdout.contains("State and coupling")); + assert!(stdout.contains("[static]")); + assert!( + stdout.lines().count() <= 40, + "report is not bounded:\n{stdout}" + ); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + #[test] fn check_over_malformed_input_exits_one_with_structured_diagnostics() { // Enough locale evidence to pass detection, then a syntax error. @@ -586,6 +614,14 @@ fn version_and_help_are_documented_contract_surfaces() { for command in ["compile", "convert", "check", "analyze", "lint", "inspect"] { assert!(help.contains(command), "help documents {command}"); } + assert!( + help.contains("semantic hotspots"), + "help distinguishes analyze" + ); + assert!( + help.contains("exhaustive structural"), + "help distinguishes inspect" + ); for option in [ "--kind", "--target", diff --git a/docs/cli.md b/docs/cli.md index d64cae9..ef69fca 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -135,9 +135,9 @@ result. | `wright compile [INPUT]` | Parse, lower, validate, emit Workshop text | the emitted artifact (or nothing with `-o`) | | `wright convert [INPUT] --target opy\|ostw` | Reconstruct validated Workshop input as canonical OPY or OSTW source | the reconstructed source | | `wright check [INPUT]` | Parse, lower, validate, and report correctness diagnostics | verdict and validation diagnostics | -| `wright analyze [INPUT]` | Report semantic structure, symbol usage, and CFG measurements | semantic facts and measurements | +| `wright analyze [INPUT]` | Summarize project structure, ranked CFG hotspots, and cross-cutting state | bounded semantic report with static evidence labels | | `wright lint [INPUT]` | Parse, lower, lint; report findings | findings, rule metadata, and effective-configuration summary | -| `wright inspect [INPUT]` | Parse, lower, inspect structure | rules, symbols, references summary | +| `wright inspect [INPUT]` | Parse, lower, and inspect exhaustive semantic facts | rules, symbols, references summary | | `wright completion ` | Generate static completion script for bash, zsh, fish, or powershell | the generated completion script | | `wright completion install [SHELL]` | Install generated completion into standard user-local directory | installation progress and guidance | | `wright update` | Self-update a standalone installation | update progress (text only) | @@ -311,11 +311,14 @@ The three core workflows have separate contracts: * `lint` executes the configurable `LintRegistry` and returns stable rule IDs, severity, evidence class, boundedness where applicable, source spans, rule metadata, and effective configuration. -* `analyze` returns semantic facts rather than lint findings. Its initial - `result.facts` report contains symbol usage (`reads`, `writes`, `calls`, and - referencing rule count) and per-rule CFG measurements (blocks, edges, loop - blocks, and wait blocks). These facts can inform future lint rules without - making analysis a view of the registry. +* `analyze` returns semantic facts rather than lint findings. Human text output + is a bounded report with a program overview, aggregate CFG measurements, + ranked rule hotspots, and ranked cross-cutting variables. The displayed + facts are static; rankings are heuristics based on CFG size or usage + coupling. `analyze --format json` retains the complete `result.facts` + payload for agents and embedding, while `inspect` is the human-facing + exhaustive structural/semantic view. These facts can inform future lint + rules without making analysis a view of the registry. Analysis findings (`lint` and the tool/agent `getFindings`/`lint` responses) carry an `evidence` field classifying how strongly the finding is supported