Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions crates/wright-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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),
Expand Down
164 changes: 112 additions & 52 deletions crates/wright-cli/src/present.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,11 @@ fn render_verdict<T: serde::Serialize>(
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)",
Expand Down Expand Up @@ -527,6 +529,10 @@ fn render_convert<T: serde::Serialize>(envelope: &Envelope<T>) {

fn render_analyze<T: serde::Serialize>(envelope: &Envelope<T>) {
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")
Expand All @@ -538,61 +544,115 @@ fn render_analyze<T: serde::Serialize>(envelope: &Envelope<T>) {
.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("<unnamed>");
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("<unnamed>");
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("<unnamed>"),
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("<unnamed>"),
reads,
writes,
)
})
.collect::<Vec<_>>();
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<T: serde::Serialize>(envelope: &Envelope<T>) {
let value = serde_json::to_value(envelope).expect("envelope serializes");
let findings = value
Expand Down
38 changes: 37 additions & 1 deletion crates/wright-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&[
Expand All @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 10 additions & 7 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SHELL>` | 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) |
Expand Down Expand Up @@ -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
Expand Down