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
66 changes: 64 additions & 2 deletions crates/wright-analyzer/src/canonical/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use workshop_rs::{Event, Program};
use super::analysis::Finding;
use super::cfg::cfg_response;
use super::facts::persistent_objects;
use super::symbols::{Reference, ReferenceKind, RuleId, SemanticIndex, Symbol, SymbolId};
use super::symbols::{
Reference, ReferenceKind, RuleId, SemanticIndex, Symbol, SymbolId, SymbolKind,
};
use crate::analysis::Boundedness;
use crate::registry::{LintConfig, SkippedRule};
use crate::service::{ErrorInfo, Origin, Request, Response};
Expand Down Expand Up @@ -155,6 +157,66 @@ impl<'a> SemanticService<'a> {
.collect(),
)
}

/// Resolve a symbol by its exact declared name (#429). A name matching no
/// symbol is `unknown-symbol`; a name shared by more than one symbol is
/// `ambiguous-symbol` with each candidate's kind and numeric id — callers
/// may fall back to the numeric form. No match is ever guessed.
pub fn resolve_symbol(&self, name: &str) -> Result<&Symbol, ErrorInfo> {
let matches: Vec<&Symbol> = self
.index
.symbols()
.filter(|symbol| symbol.name == name)
.collect();
match matches.as_slice() {
[symbol] => Ok(symbol),
[] => Err(ErrorInfo {
code: "unknown-symbol".to_string(),
message: format!("unknown symbol '{name}'"),
}),
_ => Err(ErrorInfo {
code: "ambiguous-symbol".to_string(),
message: format!(
"ambiguous symbol '{name}': {}",
matches
.iter()
.map(|symbol| format!("{} {}", symbol.kind.as_str(), symbol.id.index()))
.collect::<Vec<_>>()
.join(", ")
),
}),
}
}

/// Resolve a rule by its exact declared name to its rule index (#429).
/// Only rule symbols participate: a rule named `x` stays reachable even
/// when a variable or subroutine is also named `x`. Unmatched and
/// duplicate rule names are `unknown-rule`/`ambiguous-rule`.
pub fn resolve_rule(&self, name: &str) -> Result<RuleId, ErrorInfo> {
let matches: Vec<&Symbol> = self
.index
.symbols()
.filter(|symbol| symbol.kind == SymbolKind::Rule && symbol.name == name)
.collect();
match matches.as_slice() {
[symbol] => Ok(symbol.rule.expect("rule symbols carry their rule index")),
[] => Err(ErrorInfo {
code: "unknown-rule".to_string(),
message: format!("unknown rule '{name}'"),
}),
_ => Err(ErrorInfo {
code: "ambiguous-rule".to_string(),
message: format!(
"ambiguous rule '{name}': {}",
matches
.iter()
.map(|symbol| format!("rule {}", symbol.rule.expect("rule symbol")))
.collect::<Vec<_>>()
.join(", ")
),
}),
}
}
pub fn handle_json(&self, request_json: &str) -> String {
let request: Request = match serde_json::from_str(request_json) {
Ok(req) => req,
Expand All @@ -180,7 +242,7 @@ impl<'a> SemanticService<'a> {
Request::ListSymbols { kind } => Response::Ok { result: json!(self.index.symbols().filter(|symbol| kind.as_deref().is_none_or(|kind| symbol.kind.as_str() == kind)).map(symbol_json).collect::<Vec<_>>()) },
Request::GetSymbol { symbol } => self.index.symbol(SymbolId::from_index(*symbol as usize)).map_or_else(|| self.error("invalid-id", format!("unknown symbol {symbol}")), |symbol| Response::Ok { result: symbol_json(symbol) }),
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: json!(self.index.references(id).into_iter().map(reference_json).collect::<Vec<_>>()) } } }
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!({"symbol": data.name, "reads": usage.reads, "writes": usage.writes, "calls": usage.calls, "rules": usage.rules}) } }) }
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: json!(self.findings.iter().map(finding_json).collect::<Vec<_>>()) },
Request::GetPersistentObjects => Response::Ok { result: json!(persistent_objects(self.program.as_ref())) },
Expand Down
70 changes: 70 additions & 0 deletions crates/wright-analyzer/tests/workshop_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1078,3 +1078,73 @@ fn workshop_references_without_provenance_are_unmapped() {
);
}
}

// ── Name addressing (#429) ────────────────────────────────────────────────────

const DUPLICATE_NAMES: &str = r#"
variables {
global:
0: dup
1: solo
}
rule ("dup") {
event {
Ongoing - Global;
}
actions {
Set Global Variable(dup, 1);
}
}
rule ("dup") {
event {
Ongoing - Global;
}
actions {
Set Global Variable(dup, 2);
}
}
"#;

#[test]
fn name_resolution_finds_unique_symbols_and_rules() {
let service = workshop_service("synthetic/declarations-rules");
let score = service.resolve_symbol("score").unwrap();
assert_eq!(score.name, "score");
assert_eq!(score.id.index(), 0);
// `player starts` is rule index 1 but symbol id 4 — the two spaces are
// different, which is why callers address by name.
assert_eq!(service.resolve_rule("player starts").unwrap(), 1);
assert_eq!(
service.resolve_symbol("player starts").unwrap().id.index(),
4
);
}

#[test]
fn name_resolution_rejects_unknown_and_ambiguous_names() {
let service = workshop_service_from_text(DUPLICATE_NAMES);
let error = service.resolve_symbol("nope").unwrap_err();
assert_eq!(error.code, "unknown-symbol");
assert!(error.message.contains("nope"));
let error = service.resolve_rule("nope").unwrap_err();
assert_eq!(error.code, "unknown-rule");

// A name matching only a non-rule symbol is still unknown as a rule:
// the rule space resolves rules only.
let error = service.resolve_rule("solo").unwrap_err();
assert_eq!(error.code, "unknown-rule");
assert!(service.resolve_symbol("solo").is_ok());

// `dup` names one variable and two rules; both resolutions fail with
// the candidate list instead of guessing.
let error = service.resolve_symbol("dup").unwrap_err();
assert_eq!(error.code, "ambiguous-symbol");
for candidate in ["globalVariable 0", "rule 2", "rule 3"] {
assert!(error.message.contains(candidate), "{error:?}");
}
let error = service.resolve_rule("dup").unwrap_err();
assert_eq!(error.code, "ambiguous-rule");
for candidate in ["rule 0", "rule 1"] {
assert!(error.message.contains(candidate), "{error:?}");
}
}
6 changes: 3 additions & 3 deletions crates/wright-bench/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,9 @@ fn semantic_query_trial(
let requests = [
ToolRequest::Rules,
ToolRequest::Symbols { kind: None },
ToolRequest::References { symbol: 0 },
ToolRequest::Usage { symbol: 0 },
ToolRequest::Cfg { rule: 0 },
ToolRequest::References { symbol: 0.into() },
ToolRequest::Usage { symbol: 0.into() },
ToolRequest::Cfg { rule: 0.into() },
ToolRequest::Findings(FindingSelection::default()),
ToolRequest::PersistentObjects,
ToolRequest::Lint(FindingSelection::default()),
Expand Down
113 changes: 105 additions & 8 deletions crates/wright-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ pub(crate) struct Cli {

pub(crate) const LONG_ABOUT: &str = "Wright compiler and Workshop tooling CLI.

Commands check correctness, summarize semantic hotspots, lint, inspect exhaustive
facts, compile, or reconstruct source through the typed wright-driver result envelope. `compile` and `convert`
Commands check correctness, summarize semantic hotspots, lint, compile, or
reconstruct source through the typed wright-driver result envelope. `inspect`
prints the semantic summary, and its query subcommands (symbols, refs, cfg,
callgraph, cost) expose each detail area. `compile` and `convert`
keep their source artifact stdout contracts; JSON mode prints only one
wright-result/v1 envelope to stdout. `serve` exposes the versioned
wright-agent/v1 session contract over stdio or JSON-RPC 2.0.
Expand Down Expand Up @@ -54,7 +56,7 @@ LINT OPTIONS:
--disable-rule <ID> Disable a lint rule (repeatable)
--rule-severity <ID>:<SEV> Override a lint rule severity (repeatable)

FINDING SELECTION (check, analyze, lint):
FINDING SELECTION (check, analyze, lint, inspect cost):
--severity <LEVEL> Report findings at or above a severity: error|warning|info
--rule-id <ID> Report findings from one lint rule id only
--file <PATH> Report findings in one source file (any spelling that resolves to it)
Expand All @@ -78,8 +80,13 @@ pub(crate) enum Command {
Analyze(ReportArgs),
/// Parse, lower, and report lint findings.
Lint(LintArgs),
/// Parse, lower, and show exhaustive structural/semantic facts.
Inspect(CommonArgs),
/// Parse, lower, and inspect semantic facts: the bare command prints the
/// summary, and its query subcommands expose each detail area (#429).
#[command(
args_conflicts_with_subcommands = true,
subcommand_precedence_over_arg = true
)]
Inspect(InspectArgs),
/// Generate static shell completion from the command model.
Completion(CompletionArgs),
/// Update a standalone installation.
Expand Down Expand Up @@ -133,9 +140,9 @@ pub(crate) struct ReportArgs {
pub(crate) select: SelectArgs,
}

/// Finding-selection options shared by `check`, `analyze`, and `lint`
/// (`cost` joins with the query surface, #429). Selection narrows reported
/// output only — verdicts and exit codes always reflect the complete set.
/// Finding-selection options shared by `check`, `analyze`, `lint`, and
/// `inspect cost` (#429). Selection narrows reported output only — verdicts
/// and exit codes always reflect the complete set.
#[derive(Debug, Args, Default)]
pub(crate) struct SelectArgs {
/// Report findings at or above this severity only.
Expand Down Expand Up @@ -187,6 +194,67 @@ pub(crate) struct CommonArgs {
pub(crate) color: ColorArg,
}

/// Arguments of `inspect`: an optional query subcommand naming one detail
/// area, plus the shared workflow options used by the bare summary.
#[derive(Debug, Args)]
pub(crate) struct InspectArgs {
#[command(flatten)]
pub(crate) common: CommonArgs,
#[command(subcommand)]
pub(crate) query: Option<InspectQuery>,
}

/// The `inspect` query subcommands (#429): each is the CLI entry for the
/// agent operation of the same name, so the top-level command surface stays
/// small (#439). Agent requests keep their flat operation names.
#[derive(Debug, Subcommand)]
pub(crate) enum InspectQuery {
/// List semantic symbols; `--only` narrows to one symbol kind.
Symbols(SymbolsArgs),
/// Show the references and usage counts of one symbol, addressed by name.
Refs(RefsArgs),
/// Show the control-flow graph of one rule, addressed by name.
Cfg(CfgArgs),
/// Show the subroutine call graph.
Callgraph(CommonArgs),
/// Report generated-resource counts and static findings.
Cost(ReportArgs),
}

/// Arguments of `inspect symbols`: shared workflow options plus the
/// symbol-kind filter. The filter is `--only` rather than `--kind` because
/// `CommonArgs` already assigns `--kind` to input-frontend selection.
#[derive(Debug, Args)]
pub(crate) struct SymbolsArgs {
#[command(flatten)]
pub(crate) common: CommonArgs,
/// Report only symbols of this kind.
#[arg(long, value_enum, value_name = "KIND")]
pub(crate) only: Option<SymbolKindArg>,
}

/// Arguments of `inspect refs`: the symbol name, then the shared workflow
/// options.
#[derive(Debug, Args)]
pub(crate) struct RefsArgs {
/// The declared name of the symbol to look up.
#[arg(value_name = "NAME")]
pub(crate) name: String,
#[command(flatten)]
pub(crate) common: CommonArgs,
}

/// Arguments of `inspect cfg`: the rule name, then the shared workflow
/// options.
#[derive(Debug, Args)]
pub(crate) struct CfgArgs {
/// The declared name of the rule to look up.
#[arg(value_name = "RULE")]
pub(crate) rule: String,
#[command(flatten)]
pub(crate) common: CommonArgs,
}

#[derive(Debug, Args)]
pub(crate) struct LintArgs {
#[command(flatten)]
Expand Down Expand Up @@ -340,6 +408,35 @@ pub(crate) enum ColorArg {
Never,
}

/// Symbol kinds as spelled by the semantic index; `--only` accepts the
/// canonical names plus kebab-case aliases.
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub(crate) enum SymbolKindArg {
/// `variables.global` symbols.
#[value(name = "globalVariable", alias = "global-variable")]
GlobalVariable,
/// `variables.player` symbols.
#[value(name = "playerVariable", alias = "player-variable")]
PlayerVariable,
/// Subroutines.
#[value(name = "subroutine")]
Subroutine,
/// Rules.
#[value(name = "rule")]
Rule,
}

impl SymbolKindArg {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::GlobalVariable => "globalVariable",
Self::PlayerVariable => "playerVariable",
Self::Subroutine => "subroutine",
Self::Rule => "rule",
}
}
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub(crate) enum SeverityArg {
Error,
Expand Down
44 changes: 39 additions & 5 deletions crates/wright-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,45 @@ fn run_workflow(command: Command) -> ExitCode {
wright_driver::CompilerSession::lint,
)
}
Command::Inspect(args) => run_configured(
config_from_common(&args, false),
present::Presentation::from_common(&args),
wright_driver::CompilerSession::inspect,
),
Command::Inspect(args) => match args.query {
None => run_configured(
config_from_common(&args.common, false),
present::Presentation::from_common(&args.common),
wright_driver::CompilerSession::inspect,
),
Some(cli::InspectQuery::Symbols(query)) => {
let kind = query.only.map(|kind| kind.as_str().to_string());
run_configured(
config_from_common(&query.common, false),
present::Presentation::from_common(&query.common),
move |session| session.symbols(kind),
)
}
Some(cli::InspectQuery::Refs(query)) => run_configured(
config_from_common(&query.common, false),
present::Presentation::from_common(&query.common),
move |session| session.refs(&query.name),
),
Some(cli::InspectQuery::Cfg(query)) => run_configured(
config_from_common(&query.common, false),
present::Presentation::from_common(&query.common),
move |session| session.cfg(&query.rule),
),
Some(cli::InspectQuery::Callgraph(query)) => run_configured(
config_from_common(&query, false),
present::Presentation::from_common(&query),
wright_driver::CompilerSession::callgraph,
),
Some(cli::InspectQuery::Cost(query)) => {
let mut config = config_from_common(&query.common, true);
config.selection = selection_from_args(&query.select);
run_configured(
config,
present::Presentation::from_common(&query.common),
wright_driver::CompilerSession::cost,
)
}
},
Command::Completion(_)
| Command::Update(_)
| Command::Provider(_)
Expand Down
Loading
Loading