From ae78b799613e9128271b0a30b862c76e21601d94 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:02:14 +0800 Subject: [PATCH 1/7] feat(driver): serve structured rule content through rules (ADR-0024) rules gains content and rule: with content each selected rule carries the workshop-rs rule-content-v1 document plus statement spans, bounded to 20 rules by default and always selection-wrapped. Adds wright inspect rules, schema and contract docs, and a conformance test against the owner's published schema. OverPy results report names as unavailable pending the provider capability. Bumps workshop-rs to 1.16.0; its span pruning makes the source-map-span-overflow warning unreachable for the covered input, so that test now asserts the load still serves. --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 2 +- .../wright-analyzer/src/canonical/service.rs | 29 +++++ crates/wright-analyzer/src/service.rs | 3 + crates/wright-bench/src/main.rs | 2 + crates/wright-cli/src/cli.rs | 28 ++++ crates/wright-cli/src/main.rs | 8 ++ crates/wright-cli/src/present.rs | 55 +++++++- crates/wright-cli/src/tooldefs.rs | 4 +- crates/wright-cli/tests/agent_contract.rs | 45 +++++++ crates/wright-consumer/src/workflow.rs | 2 + crates/wright-driver/src/result.rs | 7 + crates/wright-driver/src/service.rs | 81 ++++++++++-- crates/wright-driver/src/session/semantic.rs | 25 +++- crates/wright-driver/tests/service.rs | 122 ++++++++++++++++++ crates/wright-driver/tests/source_provider.rs | 12 +- docs/agent-contract.md | 25 +++- docs/cli.md | 2 +- docs/cli/commands.md | 7 +- schemas/wright-agent-v1.schema.json | 18 ++- 21 files changed, 451 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34eba455..49ce7a1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2254,9 +2254,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "workshop-rs" -version = "1.12.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c71ac7ce770b486ecd6ae8b3cc67196ba464339159d9fd48a9de22257e9ccf" +checksum = "3fc2f390bdb11a77af97123d58e80f700c35a802995ec468beadcc82536132f7" dependencies = [ "aho-corasick", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8ab2493f..3d4d2280 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/wrightkit/wright" [workspace.dependencies] # Canonical Workshop core. -workshop-rs = "1.12.0" +workshop-rs = "1.16.0" libc = "0.2" serde = "1" serde_json = "1" diff --git a/README.md b/README.md index ac4f193d..0a37b0e3 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ wright check [INPUT] # correctness diagnostics wright lint [INPUT] # lint findings wright analyze [INPUT] # cost, hotspots, and risk indicators wright inspect [INPUT] # semantic summary -wright inspect symbols|refs|cfg|callgraph|cost +wright inspect rules|symbols|refs|cfg|callgraph|cost wright rename OLD NEW [INPUT] # previews a diff; --write applies it wright compile [INPUT] # emit Workshop text wright convert [INPUT] --target opy diff --git a/crates/wright-analyzer/src/canonical/service.rs b/crates/wright-analyzer/src/canonical/service.rs index 96b75297..02285a8b 100644 --- a/crates/wright-analyzer/src/canonical/service.rs +++ b/crates/wright-analyzer/src/canonical/service.rs @@ -226,6 +226,7 @@ impl<'a> SemanticService<'a> { Request::Program => Response::Ok { result: json!({"origin": self.origin, "globalVariables": self.program.global_variables.len(), "playerVariables": self.program.player_variables.len(), "subroutines": self.program.subroutines.len(), "rules": self.program.rules.len(), "findings": self.findings.len()}) }, Request::ListRules => Response::Ok { result: json!(self.program.rules.iter().enumerate().map(|(id, rule)| json!({"id": id, "name": rule.name, "event": event_name(&rule.event), "span": span_json(self.program.rule_span(id))})).collect::>()) }, Request::GetRule { rule } => self.rule(*rule as usize), + Request::GetRuleContent { rule } => self.rule_content(*rule as usize), Request::ListSymbols { kind } => Response::Ok { result: JsonValue::Array(self.symbols_json().iter().filter(|symbol| kind.as_deref().is_none_or(|kind| symbol["kind"].as_str() == Some(kind))).cloned().collect()) }, 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: self.symbols_json()[symbol.id.index()].clone() }), 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() } } } @@ -246,6 +247,33 @@ impl<'a> SemanticService<'a> { result: json!({"id": id, "name": rule.name, "span": span_json(self.program.rule_span(id)), "disabled": rule.disabled, "event": event_name(&rule.event), "conditions": rule.conditions.len(), "actions": rule.actions.len()}), } } + /// The owner's rule-content document with Wright's statement spans added + /// to each condition and action (ADR-0024 decision 3). + fn rule_content(&self, id: RuleId) -> Response { + let Some(rule) = self.program.rules.get(id) else { + return self.error("invalid-id", format!("unknown rule {id}")); + }; + let mut content = match rule.content() { + Ok(content) => content, + Err(error) => return self.error("rule-content-unavailable", error.to_string()), + }; + for (key, span) in [("conditions", true), ("actions", false)] { + let Some(statements) = content.get_mut(key).and_then(JsonValue::as_array_mut) else { + continue; + }; + for (index, statement) in statements.iter_mut().enumerate() { + let found = if span { + self.program.condition_span(id, index) + } else { + self.program.action_span(id, index) + }; + if found.is_some() { + statement["span"] = span_json(found); + } + } + } + Response::Ok { result: content } + } fn error(&self, code: &str, message: String) -> Response { Response::Error { error: ErrorInfo { @@ -264,6 +292,7 @@ fn request_label(request: &Request) -> &'static str { Request::Program => "svc::program", Request::ListRules => "svc::listRules", Request::GetRule { .. } => "svc::getRule", + Request::GetRuleContent { .. } => "svc::getRuleContent", Request::ListSymbols { .. } => "svc::listSymbols", Request::GetSymbol { .. } => "svc::getSymbol", Request::FindReferences { .. } => "svc::findReferences", diff --git a/crates/wright-analyzer/src/service.rs b/crates/wright-analyzer/src/service.rs index 6b93fc06..d1a3e2b5 100644 --- a/crates/wright-analyzer/src/service.rs +++ b/crates/wright-analyzer/src/service.rs @@ -20,6 +20,9 @@ pub enum Request { ListRules, /// Look up one rule by id. GetRule { rule: u32 }, + /// One rule's canonical content in the `workshop-rs/rule-content-v1` + /// format, with statement spans (ADR-0024). + GetRuleContent { rule: u32 }, /// List symbols, optionally filtered by kind. ListSymbols { #[serde(default)] diff --git a/crates/wright-bench/src/main.rs b/crates/wright-bench/src/main.rs index f427393f..fbd0c714 100644 --- a/crates/wright-bench/src/main.rs +++ b/crates/wright-bench/src/main.rs @@ -285,6 +285,8 @@ fn semantic_query_trial( name: None, file: None, max: None, + rule: None, + content: false, }, ToolRequest::Symbols { kind: None, diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index ca7b0bf3..f8154ce6 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -346,6 +346,8 @@ pub(crate) struct InspectArgs { /// small (#439). Agent requests keep their flat operation names. #[derive(Debug, Subcommand)] pub(crate) enum InspectQuery { + /// List rules; `--content` adds each rule's canonical conditions and actions. + Rules(RulesArgs), /// List semantic symbols; `--only` narrows to one symbol kind. Symbols(SymbolsArgs), /// Show the references and usage counts of one symbol, addressed by name. @@ -358,6 +360,32 @@ pub(crate) enum InspectQuery { Cost(ReportArgs), } +/// Arguments of `inspect rules`: shared workflow options plus the rule +/// selection fields and `--content` (ADR-0024). +#[derive(Debug, Args)] +pub(crate) struct RulesArgs { + #[command(flatten)] + pub(crate) common: CommonArgs, + /// Add each selected rule's conditions and actions as canonical call + /// trees; without `--max`, at most 20 rules are returned. + #[arg(long)] + pub(crate) content: bool, + /// Report only the rule with this declared name. + #[arg(long, value_name = "RULE")] + pub(crate) rule: Option, + /// Report only rules with this declared name. + #[arg(long = "only", value_name = "NAME")] + pub(crate) name: Option, + /// Report only rules located in this source file; any spelling that + /// resolves to the same file selects it. + #[arg(long, value_name = "PATH")] + pub(crate) file: Option, + /// Report at most N rules; withheld rules are reported, never silently + /// dropped. + #[arg(long, value_name = "N")] + pub(crate) max: Option, +} + /// 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. diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index d425f4c5..3f14a725 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -200,6 +200,14 @@ fn run_workflow(command: Command) -> ExitCode { None, wright_driver::CompilerSession::inspect, ), + Some(cli::InspectQuery::Rules(query)) => run_configured( + config_from_common(&query.common, false), + present::Presentation::from_common(&query.common), + None, + move |session| { + session.rules(query.name, query.rule, query.file, query.max, query.content) + }, + ), Some(cli::InspectQuery::Symbols(query)) => { let kind = query.only.map(|kind| kind.as_str().to_string()); run_configured( diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index 43a6a3c1..92880d61 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -16,7 +16,8 @@ use wright_driver::edit::RenameResult; use wright_driver::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; use wright_driver::result::{ AnalyzeResult, BriefResult, CallGraphResult, CfgResult, CheckResult, CompileResult, - ConvertResult, CostResult, Envelope, InspectResult, LintResult, RefsResult, SymbolsResult, + ConvertResult, CostResult, Envelope, InspectResult, LintResult, RefsResult, RulesResult, + SymbolsResult, }; use crate::cli::{ColorArg, CommonArgs, OutputFormatArg, RendererArg}; @@ -874,6 +875,58 @@ fn print_query_withheld(value: &serde_json::Value, noun: &str) { } } +impl ResultPresentation for RulesResult { + fn metadata(&self) -> Option { + Some(format!("{} rule(s)", query_items(&self.0, "rules").len())) + } + /// One entry per rule: identity and location, then — with `--content` — + /// the conditions and actions, each statement one call-tree line. + fn render_body(&self, _ctx: &RenderContext<'_>) { + let rules = query_items(&self.0, "rules"); + println!("\nRules"); + if rules.is_empty() { + println!(" none"); + return; + } + for rule in rules { + let name = rule["name"].as_str().unwrap_or(""); + let event = rule["event"].as_str().unwrap_or("?"); + match rule.get("span").and_then(inline_location) { + Some(location) => println!(" {name} [{event}] --> {location}"), + None => println!(" {name} [{event}]"), + } + let content = &rule["content"]; + for condition in content["conditions"].as_array().into_iter().flatten() { + println!(" if {}", render_node(&condition["value"])); + } + for action in content["actions"].as_array().into_iter().flatten() { + println!(" {}", render_node(action)); + } + } + print_query_withheld(&self.0, "rule(s)"); + } +} + +/// One rule-content node as a single `name(arg, ...)` line. +fn render_node(node: &serde_json::Value) -> String { + if let Some(call) = node["call"].as_str() { + let args = node["args"] + .as_array() + .map(|args| args.iter().map(render_node).collect::>().join(", ")) + .unwrap_or_default(); + return format!("{call}({args})"); + } + if let (Some(domain), Some(member)) = (node["enum"].as_str(), node["member"].as_str()) { + return format!("{domain}.{member}"); + } + for key in ["variable", "subroutine", "string", "localizedString"] { + if let Some(text) = node[key].as_str() { + return format!("{key}:{text}"); + } + } + node.to_string() +} + impl ResultPresentation for SymbolsResult { fn metadata(&self) -> Option { Some(format!( diff --git a/crates/wright-cli/src/tooldefs.rs b/crates/wright-cli/src/tooldefs.rs index e3ac6f51..d6e7b3e7 100644 --- a/crates/wright-cli/src/tooldefs.rs +++ b/crates/wright-cli/src/tooldefs.rs @@ -52,8 +52,8 @@ pub(crate) const TOOL_SPECS: &[ToolSpec] = &[ request_def: "RulesRequest", drop_fields: &[], mcp: true, - description: "The canonical Workshop rules in the loaded program, optionally narrowed by `name`, `file`, or `max`.", - result: "the program's rules, selection-wrapped when a selector was sent", + description: "The canonical Workshop rules in the loaded program, optionally narrowed by `name`, `rule`, `file`, or `max`; `content` adds each rule's conditions and actions as canonical call trees.", + result: "the program's rules, selection-wrapped when a selector or `content` was sent", }, ToolSpec { op: "symbols", diff --git a/crates/wright-cli/tests/agent_contract.rs b/crates/wright-cli/tests/agent_contract.rs index 9dc311a5..dea9f7aa 100644 --- a/crates/wright-cli/tests/agent_contract.rs +++ b/crates/wright-cli/tests/agent_contract.rs @@ -152,6 +152,8 @@ fn service_responses() -> Vec<(String, Value)> { name: None, file: None, max: None, + rule: None, + content: false, }) { ToolResponse::Ok { result } => result[0]["id"].as_u64().unwrap(), ToolResponse::Error { error } => panic!("rules failed: {error:?}"), @@ -590,3 +592,46 @@ fn cli_and_agent_selections_return_the_same_set() { assert_eq!(cli, agent, "{request:?} differs between CLI and service"); } } + +#[test] +fn rule_content_conforms_to_the_owner_schema() { + let input = workspace_root().join("tests/fixtures/workshop/synthetic/declarations-rules.ws"); + let mut session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(input), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }) + .expect("session starts"); + let mut service = ToolService::new(&mut session).expect("service loads the project"); + let ToolResponse::Ok { result } = service.handle(&ToolRequest::Rules { + name: None, + rule: None, + file: None, + max: None, + content: true, + }) else { + panic!("rules with content failed"); + }; + let owner: Value = + serde_json::from_str(workshop_rs::rules::content::RULE_CONTENT_V1_SCHEMA).unwrap(); + let owner = JSONSchema::compile(&owner).expect("owner schema compiles"); + let wright: Value = serde_json::from_str( + &std::fs::read_to_string(workspace_root().join("schemas/wright-agent-v1.schema.json")) + .unwrap(), + ) + .unwrap(); + let mut response = wright.clone(); + response["$ref"] = json!("#/$defs/RulesResult"); + let response = JSONSchema::compile(&response).expect("response schema compiles"); + assert!(response.is_valid(&result), "{result}"); + for rule in result["rules"].as_array().unwrap() { + // Wright adds `span` to each statement; the owner's document has none. + let mut content = rule["content"].clone(); + for key in ["conditions", "actions"] { + for statement in content[key].as_array_mut().unwrap() { + statement.as_object_mut().unwrap().remove("span"); + } + } + assert!(owner.is_valid(&content), "{content}"); + } +} diff --git a/crates/wright-consumer/src/workflow.rs b/crates/wright-consumer/src/workflow.rs index 3942d886..cf0b9833 100644 --- a/crates/wright-consumer/src/workflow.rs +++ b/crates/wright-consumer/src/workflow.rs @@ -66,6 +66,8 @@ pub fn run_consumer(input: &str) -> Result<(), String> { name: None, file: None, max: None, + rule: None, + content: false, }, ToolRequest::Findings(FindingSelection::default()), ToolRequest::Lint { diff --git a/crates/wright-driver/src/result.rs b/crates/wright-driver/src/result.rs index 9ec1d260..30560610 100644 --- a/crates/wright-driver/src/result.rs +++ b/crates/wright-driver/src/result.rs @@ -111,6 +111,13 @@ pub struct InspectResult { pub references: serde_json::Value, } +/// `rules` result (ADR-0024): the `rules` agent operation's payload verbatim +/// — with `content`, a selection-wrapped list whose entries carry the +/// owner's canonical rule content. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(transparent)] +pub struct RulesResult(pub serde_json::Value); + /// `symbols` result (#429): the `symbols` agent operation's payload verbatim /// — an array of `{id, kind, name, span}` entries. #[derive(Debug, Clone, Default, Serialize)] diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index b3422cfd..df06a6fb 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -35,6 +35,9 @@ pub const SERVICE_NAME: &str = "wright-tool-service"; pub const SERVICE_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const AGENT_CONTRACT: &str = "wright-agent/v1"; +/// Rules returned by a `content` request that sets no `max` (ADR-0024). +const DEFAULT_CONTENT_RULES: usize = 20; + /// Every operation `capabilities.operations` advertises, in contract order — /// the catalog client tool definitions are emitted from (#535). pub const OPERATIONS: &[&str] = &[ @@ -124,14 +127,19 @@ pub enum ToolRequest { /// The loaded canonical program summary (origin, files, counts, findings). Project, /// Every rule, optionally narrowed by an inline selection (`name`, - /// `file`, `max`; #531). + /// `rule`, `file`, `max`; #531). `content` adds each selected rule's + /// canonical content (ADR-0024). Rules { #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + rule: Option
, + #[serde(default, skip_serializing_if = "Option::is_none")] file: Option, #[serde(default, skip_serializing_if = "Option::is_none")] max: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + content: bool, }, /// Symbols, optionally filtered by `kind` and narrowed by an inline /// selection (`file`, `max`; #531). @@ -800,7 +808,13 @@ impl<'a> ToolService<'a> { ToolResponse::Ok { result } } ToolRequest::Project => self.ok(self.project()), - ToolRequest::Rules { name, file, max } => self.rules(name, file, max), + ToolRequest::Rules { + name, + rule, + file, + max, + content, + } => self.rules(name, rule.as_ref(), file, max, *content), ToolRequest::Symbols { kind, file, max } => { self.symbols(kind.as_deref(), file.as_deref(), *max) } @@ -1150,12 +1164,17 @@ impl<'a> ToolService<'a> { /// `rules`: every rule with resolved span paths, like `symbols` — a /// `file` id without its path is unusable to a caller. The selection - /// fields are `name` (a declared rule name), `file`, and `max` (#531). + /// fields are `name` (a declared rule name), `rule` (one rule address), + /// `file`, and `max` (#531). With `content`, each selected rule carries + /// its canonical content, `max` defaults to [`DEFAULT_CONTENT_RULES`], + /// and the selection shape is always returned (ADR-0024). fn rules( &self, name: &Option, + rule: Option<&Address>, file: &Option, max: &Option, + content: bool, ) -> ToolResponse { if let Some(name) = name { if !self @@ -1168,22 +1187,68 @@ impl<'a> ToolService<'a> { return invalid_selection(format!("unknown rule '{name}'")); } } - let selected = name.is_some() || file.is_some() || max.is_some(); + let rule_id = match rule { + Some(address) => match self.rule_address(address) { + Ok(id) if (id as usize) < self.snapshot().program.rules.len() => Some(id as u64), + Ok(id) => return invalid_selection(format!("unknown rule {id}")), + Err(error) => return invalid_selection(error.message), + }, + None => None, + }; + let max = if content { + max.or(Some(DEFAULT_CONTENT_RULES)) + } else { + *max + }; + let selected = + content || name.is_some() || rule_id.is_some() || file.is_some() || max.is_some(); let response = self.semantic_query_with_resolved_span_paths(Request::ListRules); - match response { + let response = match response { ToolResponse::Ok { result } => self.select_result( "rules", result, file.as_deref(), - |rule| { + |item| { name.as_deref() - .is_none_or(|name| rule["name"].as_str() == Some(name)) + .is_none_or(|name| item["name"].as_str() == Some(name)) + && rule_id.is_none_or(|id| item["id"].as_u64() == Some(id)) }, - *max, + max, selected, ), other => other, + }; + if content { + self.attach_rule_content(response) + } else { + response + } + } + + /// Add `content` to every rule of a selected `rules` result. A rule + /// whose content cannot be rendered fails the whole call: a partial + /// result would make an assertion on it silently wrong. + fn attach_rule_content(&self, response: ToolResponse) -> ToolResponse { + let ToolResponse::Ok { mut result } = response else { + return response; + }; + // OverPy spellings for the canonical ids need a provider capability + // that no provider advertises yet (ADR-0024 decision 4). + if self.snapshot().input.kind == crate::config::SourceKind::Opy { + result["unavailable"] = json!({"names": "opy-rs"}); + } + let Some(rules) = result["rules"].as_array_mut() else { + return ToolResponse::Ok { result }; + }; + for rule in rules { + let id = rule["id"].as_u64().unwrap_or_default() as u32; + match self.semantic_query_with_resolved_span_paths(Request::GetRuleContent { rule: id }) + { + ToolResponse::Ok { result: content } => rule["content"] = content, + other => return other, + } } + ToolResponse::Ok { result } } /// `symbols`: every symbol with resolved span paths. `kind` predates diff --git a/crates/wright-driver/src/session/semantic.rs b/crates/wright-driver/src/session/semantic.rs index 05a927a7..657c9753 100644 --- a/crates/wright-driver/src/session/semantic.rs +++ b/crates/wright-driver/src/session/semantic.rs @@ -12,7 +12,7 @@ use crate::diag::{Diagnostic, Stage}; use crate::progress::{ProgressEvent, ProgressPhase, ProgressUnit}; use crate::result::{ AnalyzeResult, CallGraphResult, CfgResult, CostResult, Envelope, InspectResult, LintResult, - RefsResult, SymbolsResult, + RefsResult, RulesResult, SymbolsResult, }; use crate::service::{Address, ToolErrorInfo, ToolRequest, ToolResponse, ToolService}; @@ -396,6 +396,29 @@ impl CompilerSession { ) } + /// `rules` (ADR-0024): the `rules` operation's payload; `content` adds + /// each selected rule's canonical content, `rule` selects one rule by + /// name, and `name`/`file`/`max` bound the reported set. + pub fn rules( + &mut self, + name: Option, + rule: Option, + file: Option, + max: Option, + content: bool, + ) -> Envelope { + self.service_query("rules", move |service| { + tool_result(service.handle(&ToolRequest::Rules { + name, + rule: rule.map(Address::Name), + file, + max, + content, + })) + .map(RulesResult) + }) + } + /// `symbols` (#429): the `symbols` operation's payload; `kind` narrows to /// one symbol kind and `file`/`max` bound the reported set (#531). pub fn symbols( diff --git a/crates/wright-driver/tests/service.rs b/crates/wright-driver/tests/service.rs index cca22c78..186c5743 100644 --- a/crates/wright-driver/tests/service.rs +++ b/crates/wright-driver/tests/service.rs @@ -34,6 +34,8 @@ fn tool_service_queries_canonical_workshop() { name: None, file: None, max: None, + rule: None, + content: false, }, ToolRequest::Findings(FindingSelection::default()), ToolRequest::CostEstimate(FindingSelection::default()), @@ -438,6 +440,8 @@ fn name_addressing_matches_numeric_addressing_and_resolves_span_paths() { name: None, file: None, max: None, + rule: None, + content: false, }, ); let rule_index = |name: &str| { @@ -781,6 +785,8 @@ fn semantic_query_selection_wraps_filters_and_bounds() { name: None, file: None, max: None, + rule: None, + content: false, }, ); assert!(rules.is_array()); @@ -799,6 +805,8 @@ fn semantic_query_selection_wraps_filters_and_bounds() { name: Some("player starts".to_string()), file: None, max: None, + rule: None, + content: false, }, ); let kept = named["rules"].as_array().unwrap(); @@ -812,6 +820,8 @@ fn semantic_query_selection_wraps_filters_and_bounds() { name: None, file: Some("declarations-rules.ws".to_string()), max: Some(1), + rule: None, + content: false, }, ); assert_eq!(bounded_rules["rules"].as_array().unwrap().len(), 1); @@ -979,6 +989,8 @@ fn semantic_query_selection_refuses_unknown_filter_values() { name: Some("nope".to_string()), file: None, max: None, + rule: None, + content: false, }, ToolRequest::Symbols { kind: Some("nope".to_string()), @@ -1513,6 +1525,8 @@ fn a_changed_input_is_reloaded_and_serves_the_new_program() { name: None, file: None, max: None, + rule: None, + content: false, }, ); assert_eq!(service.reload_count(), 1); @@ -1538,6 +1552,8 @@ fn an_unchanged_input_is_never_reloaded() { name: None, file: None, max: None, + rule: None, + content: false, }, ToolRequest::Symbols { kind: None, @@ -1579,6 +1595,8 @@ fn an_unchanged_input_is_never_reloaded() { name: None, file: None, max: None, + rule: None, + content: false, }, ); assert_eq!(service.reload_count(), 1); @@ -1604,6 +1622,8 @@ fn directory_inputs_observe_added_and_removed_sources() { name: None, file: None, max: None, + rule: None, + content: false, }, ) .as_array() @@ -1627,6 +1647,8 @@ fn directory_inputs_observe_added_and_removed_sources() { name: None, file: None, max: None, + rule: None, + content: false, } ), "input-kind-ambiguous" @@ -1706,6 +1728,8 @@ fn a_failed_reload_refuses_requests_until_the_input_heals() { name: None, file: None, max: None, + rule: None, + content: false, }, ToolRequest::Project, ] { @@ -1868,6 +1892,8 @@ fn stale_numeric_ids_refuse_until_the_new_space_is_observed() { name: None, file: None, max: None, + rule: None, + content: false, }, ); result_of( @@ -2040,6 +2066,8 @@ fn stale_numeric_rule_filters_refuse_in_references_after_a_reload() { name: None, file: None, max: None, + rule: None, + content: false, }, ); let filtered = result_of( @@ -2227,6 +2255,8 @@ fn every_operation_is_fresh_after_a_reload_or_refuses_stale_ids() { name: None, file: None, max: None, + rule: None, + content: false, } ) .as_array() @@ -2404,6 +2434,8 @@ fn an_unloadable_input_defers_the_load_to_program_reading_requests() { name: None, file: None, max: None, + rule: None, + content: false, }, ToolRequest::Symbols { kind: None, @@ -2598,6 +2630,8 @@ fn ids_issued_before_a_failed_load_stay_stale_after_recovery() { name: None, file: None, max: None, + rule: None, + content: false, }, ); result_of( @@ -2664,6 +2698,8 @@ fn ids_issued_before_a_failed_load_stay_stale_after_recovery() { name: None, file: None, max: None, + rule: None, + content: false, }, ); result_of( @@ -3196,3 +3232,89 @@ fn a_reload_rebuilds_the_semantic_service_once() { assert_eq!(service.semantic_build_count(), 2); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn rules_content_serves_bounded_canonical_call_trees() { + let mut session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(declarations_path()), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }) + .unwrap(); + let mut service = ToolService::new(&mut session).unwrap(); + let request = |rule: Option
, max: Option| ToolRequest::Rules { + name: None, + rule, + file: None, + max, + content: true, + }; + + // Without `max` the selection shape is still returned, and every rule + // carries the owner's format. + let all = result_of(&mut service, &request(None, None)); + let rules = all["rules"].as_array().unwrap(); + assert_eq!(all["selection"]["withheld"], 0); + assert!( + rules + .iter() + .all(|rule| rule["content"]["format"] == "workshop-rs/rule-content-v1") + ); + + // `rule` selects exactly one, by name or id; the statements are catalog + // call trees with positional arguments and Wright's statement spans. + let one = result_of( + &mut service, + &request(Some(Address::Name("player starts".to_string())), None), + ); + let rule = &one["rules"][0]; + assert_eq!(one["rules"].as_array().unwrap().len(), 1); + assert_eq!(rule["name"], "player starts"); + let condition = &rule["content"]["conditions"][0]; + assert_eq!(condition["value"]["call"], "=="); + assert_eq!(condition["value"]["args"][0]["call"], "hasSpawned"); + assert_eq!(condition["value"]["args"][1], true); + assert!(condition["span"]["path"].is_string()); + let actions = rule["content"]["actions"].as_array().unwrap(); + assert_eq!(actions[0]["call"], "setPlayerVariable"); + assert_eq!( + actions[0]["args"][1], + serde_json::json!({"variable": "hasStarted"}) + ); + assert_eq!( + actions[1], + serde_json::json!({ + "call": "callSubroutine", + "args": [{"subroutine": "showStatus"}], + "span": actions[1]["span"], + }) + ); + let by_id = result_of(&mut service, &request(Some(Address::Id(1)), None)); + assert_eq!(by_id["rules"][0]["content"], rule["content"]); + + // `max` bounds the rule count and reports what it withheld; a selected + // rule's content is never truncated. + let bounded = result_of(&mut service, &request(None, Some(1))); + assert_eq!(bounded["rules"].as_array().unwrap().len(), 1); + assert_eq!(bounded["selection"]["withheld"], rules.len() - 1); + + // An unknown rule is a structured selection error. + assert!(matches!( + service.handle(&request(Some(Address::Name("nope".to_string())), None)), + ToolResponse::Error { .. } + )); + + // Without `content` the previous shape is unchanged. + let plain = result_of( + &mut service, + &ToolRequest::Rules { + name: None, + rule: None, + file: None, + max: None, + content: false, + }, + ); + assert!(plain.is_array()); + assert!(plain[0].get("content").is_none()); +} diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs index 17573f54..68f124b1 100644 --- a/crates/wright-driver/tests/source_provider.rs +++ b/crates/wright-driver/tests/source_provider.rs @@ -495,9 +495,10 @@ fn mapped_provider_project_reports_its_source_file_count() { /// A provider map may carry a span that does not resolve inside the /// retained authored source (e.g. a position recorded against an expanded /// spelling rather than the authored line). Retained-source validation is a -/// freshness audit, not a load gate: the run still serves, mapped positions -/// survive, and authored extents degrade with a warning instead of failing -/// the load (#583). +/// freshness audit, not a load gate: the run still serves and mapped +/// positions survive instead of failing the load (#583). `workshop-rs` prunes +/// spans that do not resolve in the attached source (1.13), so the load no +/// longer needs its `source-map-span-overflow` fallback for this input. #[test] fn mapped_span_outside_retained_source_degrades_extents_not_the_load() { let (dir, entry) = temp_entry(); @@ -517,11 +518,6 @@ fn mapped_span_outside_retained_source_degrades_extents_not_the_load() { session.load().expect("loaded").provenance, wright_driver::Provenance::Mapped ); - assert!( - lint.diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "source-map-span-overflow") - ); cleanup(dir); } diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 82227211..a38cbb8a 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -180,7 +180,7 @@ the successful `result` payload. | `analyze` | optional `brief` | `wright-result/v1` analysis envelope; the brief form when `brief` is true | | `inspect` | optional `brief` | `wright-result/v1` inspection envelope; the brief form when `brief` is true | | `project` | none | Loaded program origin, files, counts, and findings summary | -| `rules` | optional selection | Canonical Workshop rules — each entry carries `id`, `name`, `event` (the rule's event binding, `subroutine:` for subroutine rules), and `span`; `{"rules": [...], "selection": {...}}` when a selection is applied | +| `rules` | optional selection, optional `content` | Canonical Workshop rules — each entry carries `id`, `name`, `event` (the rule's event binding, `subroutine:` for subroutine rules), and `span`; `{"rules": [...], "selection": {...}}` when a selection is applied. See [Rule content](#rule-content-adr-0024) | | `symbols` | optional selection | Symbols; `{"symbols": [...], "selection": {...}}` when `file`/`max` is applied (a `kind`-only request keeps the bare array) | | `references` | required `symbol` (id or name), optional selection | References for the symbol; `{"references": [...], "selection": {...}}` when a selection is applied | | `usage` | required `symbol` (id or name) | Usage counts for the symbol, plus its resolved `id` and `kind` | @@ -329,6 +329,26 @@ When a request applies any selection field, the result reports and `costEstimate` add a `selection` member to their existing result objects. Requests without selection fields receive the previous shapes unchanged. +### Rule content (ADR-0024) + +`rules` with `"content": true` adds a `content` member to each returned rule: +the owner's `workshop-rs/rule-content-v1` document. Every action, value, and +operator is a `{"call": id, "args": [...]}` node named by its catalog id, and +`args` lists every argument in catalog parameter order, so an argument is +identified by its position (`lookup` names the parameter at each position). +Literals, `{"enum", "member"}`, `{"variable"}`, `{"subroutine"}`, `{"string"}`, +and `{"localizedString"}` are the other node forms. `actions` is flat: index +`i` is the same action index `cfg`, `references`, and findings use. Wright adds +`span` to each condition and action when the program has one. For a source +language the content is the canonical program after lowering, so assertions are +independent of spelling and locale. + +A `content` request without `max` returns at most 20 rules, always in the +selection-wrapped shape; `rule` selects exactly one. A selected rule's content +is never truncated. The CLI form is `wright inspect rules --content` +(`--rule`, `--only`, `--file`, `--max`). OverPy spellings for the canonical ids +(`names`) are not served yet: they wait on a provider capability. + ### Semantic query selection (#531) `rules`, `symbols`, `references`, `cfg`, and `callGraph` accept optional @@ -339,7 +359,8 @@ value — a kind outside the listed domain, or a `name`/`rule`/`caller`/`callee` that matches nothing in the loaded program — is a structured `invalid-selection` error, never a silent empty result. -* `rules`: `name` (a declared rule name), `file`, `max`. +* `rules`: `name` (a declared rule name), `rule` (exactly one rule, by index + or declared name), `file`, `max`. * `symbols`: `file`, `max`, plus the pre-existing `kind` (`globalVariable`, `playerVariable`, `subroutine`, `rule`). `kind` predates this selection contract: a request carrying only `kind` keeps the previous filtered bare diff --git a/docs/cli.md b/docs/cli.md index 48060222..fad858c0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,7 +23,7 @@ See [architecture, commands, and conversion](cli/commands.md). ## Semantic query commands (#429) See [architecture, commands, and conversion](cli/commands.md): `inspect` owns -the semantic query surface — `inspect symbols`, `inspect refs`, `inspect +the semantic query surface — `inspect rules`, `inspect symbols`, `inspect refs`, `inspect cfg`, `inspect callgraph`, and `inspect cost` serve the same results as the agent operations, and `refs`/`cfg` address symbols and rules by name. diff --git a/docs/cli/commands.md b/docs/cli/commands.md index f800977b..2a015716 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -36,6 +36,7 @@ result. | `wright analyze [INPUT] [--brief]` | Summarize Workshop cost, ranked complexity hotspots, performance/stability risk indicators, and cross-cutting state | bounded semantic report with exact/static/heuristic evidence labels; `--brief` prints the counts-and-costliest-rules form | | `wright lint [INPUT] [--brief] [--fix [--write]]` | Parse, lower, lint; report findings | findings, rule id/severity summary, and effective configuration; `--brief` prints the counts-and-top-findings form; `--fix` previews each offered fix's validated diff, `--write` applies them | | `wright inspect [INPUT] [--brief]` | Parse, lower, and inspect exhaustive semantic facts | rules, symbols, references summary, and the detail command per area; `--brief` prints the counts-and-leading-rules form | +| `wright inspect rules [INPUT] [--content] [--rule RULE] [--only NAME] [--file PATH] [--max N]` | List rules; `--content` adds each selected rule's conditions and actions as canonical call trees (ADR-0024) | the rule list, with content when requested | | `wright inspect symbols [INPUT] [--only KIND] [--file PATH] [--max N]` | List semantic symbols, optionally narrowed to one kind | the symbol list with resolved locations | | `wright inspect refs [INPUT] [--only KIND] [--rule RULE] [--file PATH] [--max N]` | References and usage counts for one symbol, addressed by name | usage-count header plus the reference list | | `wright inspect cfg [INPUT] [--only KIND] [--max N]` | Control-flow graph of one rule, addressed by name | block/edge listing | @@ -180,10 +181,10 @@ do not take `--brief`. ## Semantic query commands (#429) `inspect` owns the semantic query surface: the bare command prints the -bounded summary, and its five subcommands — `inspect symbols`, +bounded summary, and its six subcommands — `inspect rules`, `inspect symbols`, `inspect refs`, `inspect cfg`, `inspect callgraph`, `inspect cost` — expose each detail area. They run the same operations the agent contract serves -(`symbols`, `references` + `usage`, `cfg`, `callGraph`, `costEstimate`) +(`rules`, `symbols`, `references` + `usage`, `cfg`, `callGraph`, `costEstimate`) through the session's `ToolService`, so a CLI `result` payload equals the agent operation's payload for the same input. Nesting them under `inspect` keeps the top-level command surface to distinct user intents (#439); the @@ -209,7 +210,7 @@ resolution never guesses. `--only` predates the selection contract, so used alone it keeps the previous bare list; `--file`/`--max` opt into the selection wrapper. * The query subcommands accept the same selection fields the agent - operations take (#531): `inspect symbols` adds `--file`/`--max`, + operations take (#531): `inspect rules` adds `--rule`, `--only` (a declared rule name), `--file`, `--max`, and `--content` (without `--max`, `--content` returns at most 20 rules), `inspect symbols` adds `--file`/`--max`, `inspect refs` adds `--only` (a reference kind), `--rule`, `--file`, and `--max`, `inspect cfg` adds `--only` (a block kind) and `--max`, and `inspect callgraph` adds `--caller`, `--callee`, and `--max`. With any diff --git a/schemas/wright-agent-v1.schema.json b/schemas/wright-agent-v1.schema.json index 607ca9fa..fd89d960 100644 --- a/schemas/wright-agent-v1.schema.json +++ b/schemas/wright-agent-v1.schema.json @@ -195,7 +195,19 @@ "type": "string", "description": "The rule's event binding: a canonical event id, or `subroutine:` for a subroutine rule." }, - "span": { "$ref": "#/$defs/Span" } + "span": { "$ref": "#/$defs/Span" }, + "content": { + "type": "object", + "required": ["format", "event", "conditions", "actions"], + "description": "Present when `content` was requested: the owner's `workshop-rs/rule-content-v1` document. Each condition and action also carries `span` when the program has one.", + "properties": { + "format": { "const": "workshop-rs/rule-content-v1" }, + "event": { "type": "object" }, + "conditions": { "type": "array", "items": { "type": "object" } }, + "actions": { "type": "array", "items": { "type": "object" } } + }, + "additionalProperties": true + } }, "additionalProperties": true }, @@ -844,8 +856,10 @@ "properties": { "op": { "const": "rules" }, "name": { "type": "string", "description": "Selection (#531): only the rule with this declared name." }, + "rule": { "oneOf": [{ "type": "integer", "minimum": 0 }, { "type": "string" }], "description": "Selection (ADR-0024): exactly this rule, addressed by id or declared name." }, "file": { "type": "string", "description": "Selection (#531): only rules located in this source file; any spelling that resolves to the same file selects it." }, - "max": { "type": "integer", "minimum": 0, "description": "Selection (#531): report at most this many rules; the response's `selection.withheld` counts what the bound dropped." } + "max": { "type": "integer", "minimum": 0, "description": "Selection (#531): report at most this many rules; the response's `selection.withheld` counts what the bound dropped." }, + "content": { "type": "boolean", "description": "ADR-0024: add each returned rule's canonical content (`workshop-rs/rule-content-v1`). Without `max`, at most 20 rules are returned; the selection-wrapped result is always used." } }, "required": ["op"], "additionalProperties": false From d57e67e8acc71204ba6b30e6ed327cdb8aafe4d8 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:06:35 +0800 Subject: [PATCH 2/7] feat(bench): add the program-calls check over canonical rule content (ADR-0024) program-calls compiles the entry with the oracle or Wright, reads the Workshop text through rules with content, and counts calls by catalog id and positional argument patterns. A Wright that does not serve rule content makes the check unavailable instead of failing or passing it. Fixtures cover the Proximity-repel rule in OverPy and in en-US and zh-CN Workshop, with a one-short-stun negative. --- benchmarks/agent/bench_grade.py | 79 +++++++++++++++++++ .../program-calls/repel-one-short-stun.opy | 10 +++ .../fixtures/program-calls/repel.en-US.ow | 22 ++++++ .../agent/fixtures/program-calls/repel.opy | 10 +++ .../fixtures/program-calls/repel.zh-CN.ow | 22 ++++++ benchmarks/agent/test_agent_bench.py | 35 ++++++++ docs/agent-benchmark.md | 15 +++- .../SPEC-414-agent-benchmark-comparison.md | 3 +- 8 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 benchmarks/agent/fixtures/program-calls/repel-one-short-stun.opy create mode 100644 benchmarks/agent/fixtures/program-calls/repel.en-US.ow create mode 100644 benchmarks/agent/fixtures/program-calls/repel.opy create mode 100644 benchmarks/agent/fixtures/program-calls/repel.zh-CN.ow diff --git a/benchmarks/agent/bench_grade.py b/benchmarks/agent/bench_grade.py index 190b1243..a30bbda5 100644 --- a/benchmarks/agent/bench_grade.py +++ b/benchmarks/agent/bench_grade.py @@ -101,6 +101,83 @@ def compiled_text(state: dict, source: str) -> str | None: return (state["scratch"] / "wright.txt").read_text() if auth["wrightCompile"]["status"] == "ok" else None +def compiled_path(state: dict, source: str) -> Path | None: + """Path of the compiled Workshop text from `wright` or the upstream `oracle`, when that authority succeeded.""" + return (state["scratch"] / f"{source}.txt") if compiled_text(state, source) is not None else None + + +def rule_content(wright: str, program: Path, max_rules: int) -> dict: + """The program's rules with canonical content (ADR-0024), read from raw Workshop so no `names` are involved.""" + proc = subprocess.run([wright, "serve", "--kind", "workshop", str(program)], + input=json.dumps({"op": "rules", "content": True, "max": max_rules}) + "\n", capture_output=True, text=True) + try: + return json.loads(proc.stdout.splitlines()[0]) + except (IndexError, json.JSONDecodeError): + return {"error": {"code": "harness-output", "message": proc.stderr.strip()}} + + +def pattern_matches(pattern, node) -> bool: + """A pattern is a rule-content node: numbers compare within 1e-9, objects as a recursive subset, `{"any": [...]}` as alternatives.""" + if isinstance(pattern, dict) and set(pattern) == {"any"}: + return any(pattern_matches(alternative, node) for alternative in pattern["any"]) + if isinstance(pattern, dict): + return isinstance(node, dict) and all(k in node and pattern_matches(v, node[k]) for k, v in pattern.items()) + if isinstance(pattern, list): + return isinstance(node, list) and len(pattern) == len(node) and all(pattern_matches(p, n) for p, n in zip(pattern, node)) + if isinstance(pattern, bool) or isinstance(node, bool) or pattern is None or node is None: + return pattern is node + if isinstance(pattern, (int, float)): + return isinstance(node, (int, float)) and abs(pattern - node) <= 1e-9 + return pattern == node + + +def call_nodes(node): + """Every call node in one statement or value tree, outermost first.""" + if isinstance(node, dict) and "call" in node: + yield node + for argument in node.get("args", []): + yield from call_nodes(argument) + + +def count_calls(rules: list[dict], check: dict) -> int: + """Calls matching `call` and the positional `args` patterns, across every rule (or those with the `event` id).""" + count = 0 + for rule in rules: + content = rule["content"] + if "event" in check and content["event"]["id"] != check["event"]: + continue + statements = [condition["value"] for condition in content["conditions"]] + content["actions"] + for statement in statements: + for node in call_nodes(statement): + if node["call"] == check["call"] and all( + int(position) < len(node["args"]) and pattern_matches(pattern, node["args"][int(position)]) + for position, pattern in check.get("args", {}).items() + ): + count += 1 + return count + + +def program_calls(check: dict, entry: Path, wright: str, state: dict) -> dict: + source = check.get("source", "oracle" if entry.suffix == ".opy" else "wright") + path = compiled_path(state, source) + if path is None: + return check_result(check, False, f"no compiled text from {source}", unavailable=source == "oracle" and not oracle_available()) + cache = state.setdefault("ruleContent", {}) + if source not in cache: + cache[source] = rule_content(wright, path, check.get("maxRules", 200)) + response = cache[source] + if "error" in response: + # A Wright without the rules `content` field cannot grade this: unavailable, never a pass. + return check_result(check, False, f"rule content unavailable: {response['error'].get('message', response['error'])}", unavailable=True) + result = response["result"] + if not isinstance(result, dict) or any("content" not in rule for rule in result.get("rules", [])): + return check_result(check, False, "this wright does not serve rule content", unavailable=True) + if result["selection"]["withheld"]: + return check_result(check, False, f"incomplete: {result['selection']['withheld']} rule(s) withheld by maxRules") + count = count_calls(result["rules"], check) + return check_result(check, check.get("min", 1) <= count <= check.get("max", count), f"{count} matching '{check['call']}' call(s) in {source} output") + + def check_result(check: dict, passed: bool, detail: str, **extra) -> dict: return {"id": check["id"], "kind": check["kind"], "layer": check.get("layer", "agent"), "passed": passed, "detail": detail, **extra} @@ -159,6 +236,8 @@ def run_check(check: dict, workspace: Path, entry: Path, wright: str, state: dic return check_result(check, False, f"no compiled text from {source}", unavailable=source == "oracle" and not oracle_available()) found = matches(text, check) return check_result(check, found if kind == "compiled-contains" else not found, f"{'matched' if found else 'no match'} in {source} output") + if kind == "program-calls": + return program_calls(check, entry, wright, state) raise SystemExit(f"unknown check kind '{kind}'") diff --git a/benchmarks/agent/fixtures/program-calls/repel-one-short-stun.opy b/benchmarks/agent/fixtures/program-calls/repel-one-short-stun.opy new file mode 100644 index 00000000..cba6c164 --- /dev/null +++ b/benchmarks/agent/fixtures/program-calls/repel-one-short-stun.opy @@ -0,0 +1,10 @@ +playervar other + +rule "Proximity repel": + @Event eachPlayer + @Condition distance(eventPlayer, eventPlayer.other) <= 6 + + eventPlayer.applyImpulse(directionTowards(eventPlayer.other, eventPlayer), 150, Relativity.TO_WORLD, Impulse.CANCEL_CONTRARY_MOTION_XYZ) + eventPlayer.other.applyImpulse(directionTowards(eventPlayer, eventPlayer.other), 150, Relativity.TO_WORLD, Impulse.CANCEL_CONTRARY_MOTION_XYZ) + eventPlayer.setStatusEffect(null, Status.STUNNED, 0.75) + eventPlayer.other.setStatusEffect(null, Status.STUNNED, 0.5) diff --git a/benchmarks/agent/fixtures/program-calls/repel.en-US.ow b/benchmarks/agent/fixtures/program-calls/repel.en-US.ow new file mode 100644 index 00000000..649c50a5 --- /dev/null +++ b/benchmarks/agent/fixtures/program-calls/repel.en-US.ow @@ -0,0 +1,22 @@ +variables { + player: + 0: other +} + +rule ("Proximity repel") { + event { + Ongoing - Each Player; + All; + All; + } + conditions { + Distance Between(Event Player, (Event Player).other) <= 6; + } + actions { + Apply Impulse(Event Player, Direction Towards((Event Player).other, Event Player), 150, To World, Cancel Contrary Motion XYZ); + Apply Impulse((Event Player).other, Direction Towards(Event Player, (Event Player).other), 150, To World, Cancel Contrary Motion XYZ); + Set Status(Event Player, Null, Stunned, 0.75); + Set Status((Event Player).other, Null, Stunned, 0.75); + } +} + diff --git a/benchmarks/agent/fixtures/program-calls/repel.opy b/benchmarks/agent/fixtures/program-calls/repel.opy new file mode 100644 index 00000000..1c392c99 --- /dev/null +++ b/benchmarks/agent/fixtures/program-calls/repel.opy @@ -0,0 +1,10 @@ +playervar other + +rule "Proximity repel": + @Event eachPlayer + @Condition distance(eventPlayer, eventPlayer.other) <= 6 + + eventPlayer.applyImpulse(directionTowards(eventPlayer.other, eventPlayer), 150, Relativity.TO_WORLD, Impulse.CANCEL_CONTRARY_MOTION_XYZ) + eventPlayer.other.applyImpulse(directionTowards(eventPlayer, eventPlayer.other), 150, Relativity.TO_WORLD, Impulse.CANCEL_CONTRARY_MOTION_XYZ) + eventPlayer.setStatusEffect(null, Status.STUNNED, 0.75) + eventPlayer.other.setStatusEffect(null, Status.STUNNED, 0.75) diff --git a/benchmarks/agent/fixtures/program-calls/repel.zh-CN.ow b/benchmarks/agent/fixtures/program-calls/repel.zh-CN.ow new file mode 100644 index 00000000..7a3967b3 --- /dev/null +++ b/benchmarks/agent/fixtures/program-calls/repel.zh-CN.ow @@ -0,0 +1,22 @@ +变量 { + 玩家: + 0: other +} + +规则 ("Proximity repel") { + 事件 { + 持续 - 每名玩家; + 双方; + 全部; + } + 条件 { + 相距距离(事件玩家, (事件玩家).other) <= 6; + } + 动作 { + 施加推力(事件玩家, 方向((事件玩家).other, 事件玩家), 150, 至地图, 取消相反运动XYZ); + 施加推力((事件玩家).other, 方向(事件玩家, (事件玩家).other), 150, 至地图, 取消相反运动XYZ); + 设置状态(事件玩家, 空, 击晕, 0.75); + 设置状态((事件玩家).other, 空, 击晕, 0.75); + } +} + diff --git a/benchmarks/agent/test_agent_bench.py b/benchmarks/agent/test_agent_bench.py index 6083ccc0..86d56205 100644 --- a/benchmarks/agent/test_agent_bench.py +++ b/benchmarks/agent/test_agent_bench.py @@ -368,6 +368,41 @@ def test_self_kill_exclusion_accepts_equivalent_forms_and_rejects_its_absence(se self.assertTrue(bench_grade.matches(text, scored), text) self.assertFalse(bench_grade.matches("Ongoing - Each Player;", scored)) + def program_calls(self, fixture: str, checks: list[dict], source: str = "wright") -> list[bool]: + """Run `program-calls` checks over one committed fixture, compiled by the named authority.""" + entry = agent_bench.HERE / "fixtures/program-calls" / fixture + scratch = self.out / f"program-calls-{fixture}-{source}" + state = {"scratch": scratch, "authorities": bench_grade.authorities(WRIGHT, entry, scratch)} + return [bench_grade.run_check({"id": "c", "kind": "program-calls", "source": source, **check}, entry.parent, entry, WRIGHT, state)["passed"] for check in checks] + + REPEL_CHECKS = ( + {"call": "applyImpulse", "args": {"2": 150}, "min": 2}, + {"call": "setStatusEffect", "args": {"2": {"enum": "Status", "member": "STUNNED"}, "3": 0.75}, "min": 2}, + ) + + def test_program_calls_asserts_on_canonical_calls_in_every_surface_form(self): + for fixture in ("repel.opy", "repel.en-US.ow", "repel.zh-CN.ow"): + self.assertEqual(self.program_calls(fixture, list(self.REPEL_CHECKS)), [True, True], fixture) + + def test_program_calls_rejects_a_missing_or_wrong_argument(self): + self.assertEqual(self.program_calls("repel-one-short-stun.opy", list(self.REPEL_CHECKS)), [True, False]) + self.assertEqual(self.program_calls("repel.opy", [{"call": "applyImpulse", "args": {"2": 151}, "min": 1}, {"call": "applyImpulse", "args": {"2": 150}, "max": 1}]), [False, False]) + + def test_program_calls_alternatives_and_event_restriction(self): + any_of = {"call": "setStatusEffect", "args": {"3": {"any": [0.5, 0.75]}}, "min": 2} + self.assertEqual(self.program_calls("repel.opy", [any_of, {**any_of, "event": "global"}, {**any_of, "event": "eachPlayer"}]), [True, False, True]) + + @unittest.skipUnless(bench_grade.oracle_available(), "run `agent_bench.py setup-oracle`") + def test_program_calls_reads_the_upstream_oracle_output(self): + self.assertEqual(self.program_calls("repel.opy", list(self.REPEL_CHECKS), source="oracle"), [True, True]) + + def test_pattern_matching_compares_numbers_by_value_and_never_confuses_booleans(self): + self.assertTrue(bench_grade.pattern_matches(0.75, 0.75)) + self.assertTrue(bench_grade.pattern_matches(150, 150.0)) + self.assertFalse(bench_grade.pattern_matches(1, True)) + self.assertTrue(bench_grade.pattern_matches({"enum": "Status"}, {"enum": "Status", "member": "STUNNED"})) + self.assertFalse(bench_grade.pattern_matches({"enum": "Status", "member": "HACKED"}, {"enum": "Status", "member": "STUNNED"})) + def test_scenarios_are_solvable_and_not_vacuous(self): self.assertTrue(agent_bench.validate(WRIGHT, self.out / "validate")) diff --git a/docs/agent-benchmark.md b/docs/agent-benchmark.md index a0af6d98..b9d2ef7a 100644 --- a/docs/agent-benchmark.md +++ b/docs/agent-benchmark.md @@ -272,8 +272,19 @@ of alternatives, `min`/`max` occurrences), `answer` (`answer.json` key equals `expected`), `oracle` (the pinned upstream OverPy compiler accepts the entry), `wright-compile` (`wright compile` succeeds), and `compiled-contains` / `compiled-absent` (regexes `all` and `any` over the compiled Workshop text of -`source` `oracle` or `wright`). Prefer structural checks; a regex over compiled -text needs a `reference` that matches and a `negative` that does not. `layer` +`source` `oracle` or `wright`), and `program-calls` (a structured check, ADR-0024: +the compiled text of `source` is read through `rules` with `content`, and the +check counts calls whose catalog id equals `call` and whose positional `args` +patterns match, across all rules or those with the `event` id, within +`min`/`max`). A pattern is a rule-content node: numbers compare within 1e-9, +objects as a recursive subset, and `{"any": [...]}` lists alternatives. The +check is stable across spelling, locale, and macro expansion, but not across +restructured equivalents such as a value held in a variable, so list those +under `any` or assert the pieces separately. A Wright that does not serve +rule content makes the check `unavailable`, never a pass; `maxRules` (default +200) bounds the read and an incomplete read fails. Prefer `program-calls` over +a regex; a regex over compiled text needs a `reference` that matches and a +`negative` that does not. `layer` names what a failure implicates: `agent` for a requirement the produced work does not meet, or `workshop-rs` / `opy-rs` / `deltin-rs` / `wright` for validity or analysis results owned by that layer. diff --git a/docs/specs/SPEC-414-agent-benchmark-comparison.md b/docs/specs/SPEC-414-agent-benchmark-comparison.md index 299a8938..0656df07 100644 --- a/docs/specs/SPEC-414-agent-benchmark-comparison.md +++ b/docs/specs/SPEC-414-agent-benchmark-comparison.md @@ -82,7 +82,8 @@ Pilot observations that shaped this spec (Sonnet only, 2 scenarios, 18 runs; not captured minimal reproducer and the candidate owner, for creation of owner Issues. - REQ-010: Requirement checks assert on the compiled Workshop program through Wright's structured surface (`inspect` JSON or `serve` operations). A regex over emitted text is allowed only where no structured field - exists and ships with a positive and a negative fixture. + exists and ships with a positive and a negative fixture. The `program-calls` check kind (ADR-0024) reads the + canonical rule content through `rules` with `content` and asserts on calls by catalog id and argument position. - REQ-011: Reported outcome metrics are: `usable` (L1a passes, all required requirement checks pass, no error-severity lint finding), requirement coverage over all runs and over L1a-passing runs, time to first valid snapshot, valid-to-invalid regressions, wall time, and the context and token metrics of REQ-020 to REQ-024. Runtime-only claims are listed as From 026ff0b2736a9d5d6e10d6e041b60ed1ea4545b5 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:18:18 +0800 Subject: [PATCH 3/7] fix(driver): reject stale, unknown, and ambiguous rules selections rules.rule with a numeric id now joins cfg in the stale-id refusal after a reload; an unmatched name maps to invalid-selection like other selection fields while a duplicate name keeps the ambiguous-rule refusal cfg reports. Extends the CLI/agent selection parity test to inspect rules with content/max/rule, adds the provider test asserting unavailable.names=opy-rs over canonical content on an OverPy input, covers the 20-rule default bound and selection totals, and documents the unavailable member in the agent schema. --- crates/wright-cli/tests/agent_contract.rs | 57 +++++++++++++ crates/wright-driver/src/service.rs | 12 ++- crates/wright-driver/tests/service.rs | 84 +++++++++++++++++-- crates/wright-driver/tests/source_provider.rs | 71 ++++++++++++++++ schemas/wright-agent-v1.schema.json | 6 +- 5 files changed, 223 insertions(+), 7 deletions(-) diff --git a/crates/wright-cli/tests/agent_contract.rs b/crates/wright-cli/tests/agent_contract.rs index dea9f7aa..1492b2f3 100644 --- a/crates/wright-cli/tests/agent_contract.rs +++ b/crates/wright-cli/tests/agent_contract.rs @@ -543,6 +543,63 @@ fn cli_and_agent_selections_return_the_same_set() { assert_eq!(cli["symbols"], agent["symbols"], "same selected set"); assert_eq!(cli["selection"], agent["selection"], "same truncation"); + // ADR-0024: `inspect rules` serves the same driver model `rules` + // serves, including `content` and `rule` — each flag maps to the + // request field of the same name. A fixture without ambiguous enums: + // `rule-content-v1` cannot express `__ambiguous_enum`, so content + // fails whole-call there by design. + let rules_input = + workspace_root().join("tests/fixtures/workshop/synthetic/declarations-rules.ws"); + let mut rules_session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(rules_input.clone()), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }) + .expect("rules session starts"); + let mut rules_service = ToolService::new(&mut rules_session).expect("rules service loads"); + for (request, args) in [ + ( + ToolRequest::Rules { + name: None, + rule: None, + file: None, + max: Some(1), + content: true, + }, + vec!["--content", "--max", "1"], + ), + ( + ToolRequest::Rules { + name: None, + rule: Some("player starts".into()), + file: None, + max: None, + content: true, + }, + vec!["--content", "--rule", "player starts"], + ), + ] { + let agent = match rules_service.handle(&request) { + ToolResponse::Ok { result } => result, + ToolResponse::Error { error } => panic!("{request:?} failed: {error:?}"), + }; + let output = Command::new(env!("CARGO_BIN_EXE_wright")) + .args(["inspect", "rules"]) + .arg(rules_input.to_str().unwrap()) + .args(&args) + .args(["-f", "json"]) + .stdin(Stdio::null()) + .output() + .expect("wright inspect rules runs"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let cli = serde_json::from_slice::(&output.stdout).unwrap()["result"].clone(); + assert_eq!(cli, agent, "{request:?} differs between CLI and service"); + } + // #532: `--brief` and the `brief` request field produce the same brief // payload for the same input — the agent and CLI surfaces share one // driver-side form. diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index df06a6fb..4a84b8c8 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -727,6 +727,10 @@ impl<'a> ToolService<'a> { ToolRequest::Cfg { rule, .. } => { matches!(rule, Address::Id(_)) && !self.rule_ids_current } + ToolRequest::Rules { + rule: Some(Address::Id(_)), + .. + } => !self.rule_ids_current, ToolRequest::SemanticRename { target, .. } => { matches!(target.symbol, Some(Address::Id(_))) && !self.symbol_ids_current } @@ -1191,7 +1195,13 @@ impl<'a> ToolService<'a> { Some(address) => match self.rule_address(address) { Ok(id) if (id as usize) < self.snapshot().program.rules.len() => Some(id as u64), Ok(id) => return invalid_selection(format!("unknown rule {id}")), - Err(error) => return invalid_selection(error.message), + // An unmatched name is a selection error like every other + // selection field; a duplicate name keeps the `ambiguous-rule` + // refusal `cfg` reports so the caller learns the candidates. + Err(error) if error.code == "unknown-rule" => { + return invalid_selection(error.message); + } + Err(error) => return ToolResponse::Error { error }, }, None => None, }; diff --git a/crates/wright-driver/tests/service.rs b/crates/wright-driver/tests/service.rs index 186c5743..1efa572e 100644 --- a/crates/wright-driver/tests/service.rs +++ b/crates/wright-driver/tests/service.rs @@ -2028,6 +2028,13 @@ fn stale_numeric_rule_filters_refuse_in_references_after_a_reload() { file: None, max: None, }, + ToolRequest::Rules { + name: None, + rule: Some(0.into()), + file: None, + max: None, + content: true, + }, ] { assert_eq!( refusal_code(&mut service, &request), @@ -3260,6 +3267,8 @@ fn rules_content_serves_bounded_canonical_call_trees() { .iter() .all(|rule| rule["content"]["format"] == "workshop-rs/rule-content-v1") ); + // A raw Workshop result carries neither `names` nor `unavailable`. + assert!(all.get("names").is_none() && all.get("unavailable").is_none()); // `rule` selects exactly one, by name or id; the statements are catalog // call trees with positional arguments and Wright's statement spans. @@ -3298,11 +3307,18 @@ fn rules_content_serves_bounded_canonical_call_trees() { assert_eq!(bounded["rules"].as_array().unwrap().len(), 1); assert_eq!(bounded["selection"]["withheld"], rules.len() - 1); - // An unknown rule is a structured selection error. - assert!(matches!( - service.handle(&request(Some(Address::Name("nope".to_string())), None)), - ToolResponse::Error { .. } - )); + // An unknown rule, by name or by id, is an `invalid-selection` error. + assert_eq!( + refusal_code( + &mut service, + &request(Some(Address::Name("nope".to_string())), None) + ), + "invalid-selection" + ); + assert_eq!( + refusal_code(&mut service, &request(Some(Address::Id(99)), None)), + "invalid-selection" + ); // Without `content` the previous shape is unchanged. let plain = result_of( @@ -3318,3 +3334,61 @@ fn rules_content_serves_bounded_canonical_call_trees() { assert!(plain.is_array()); assert!(plain[0].get("content").is_none()); } + +/// A `content` request without `max` applies the default bound of 20 rules +/// (ADR-0024): the selection reports every withheld rule. A `rule` name +/// shared by two rules refuses `ambiguous-rule`, the refusal `cfg` gives. +#[test] +fn rules_content_defaults_to_twenty_rules_and_names_ambiguity() { + let dir = freshness_dir("rules-content-bound"); + let input = dir.join("program.ws"); + let mut text = "variables {\n global:\n 0: score\n}\n".to_string(); + for index in 0..25 { + let name = if index < 2 { + "dup".to_string() + } else { + format!("r{index}") + }; + text.push_str(&format!( + "rule (\"{name}\") {{\n event {{\n Ongoing - Global;\n }}\n actions {{\n Set Global Variable(score, {index});\n }}\n}}\n" + )); + } + std::fs::write(&input, text).unwrap(); + let mut session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(input), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }) + .unwrap(); + let mut service = ToolService::new(&mut session).unwrap(); + let request = |rule: Option
, max: Option| ToolRequest::Rules { + name: None, + rule, + file: None, + max, + content: true, + }; + + let bounded = result_of(&mut service, &request(None, None)); + assert_eq!(bounded["rules"].as_array().unwrap().len(), 20); + assert_eq!( + bounded["selection"], + serde_json::json!({"total": 25, "withheld": 5}) + ); + + let one = result_of( + &mut service, + &request(Some(Address::Name("r3".to_string())), None), + ); + assert_eq!(one["rules"].as_array().unwrap().len(), 1); + assert_eq!(one["rules"][0]["name"], "r3"); + + assert_eq!( + refusal_code( + &mut service, + &request(Some(Address::Name("dup".to_string())), None) + ), + "ambiguous-rule" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs index 68f124b1..266f84a7 100644 --- a/crates/wright-driver/tests/source_provider.rs +++ b/crates/wright-driver/tests/source_provider.rs @@ -1938,3 +1938,74 @@ fn provider_backend_inspect_remains_explicitly_unsupported() { assert!(result.result.program.is_null()); cleanup(dir); } + +/// ADR-0024 decision 4: until the provider advertises the names capability, +/// an OverPy program serves its canonical content with `unavailable` +/// naming `opy-rs` instead of a `names` map. The repel fixture stands in +/// for the compiled OverPy form; assertions read the content, never text. +#[test] +fn opy_rules_content_reports_names_unavailable() { + let (dir, entry) = temp_entry(); + let provider = RecordingProvider { + target: Arc::new(Mutex::new(None)), + operations: Arc::new(Mutex::new(Vec::new())), + check_compilation: None, + compilation: Some(SourceCompilation::success( + std::fs::read_to_string( + workspace_root().join("benchmarks/agent/fixtures/program-calls/repel.en-US.ow"), + ) + .expect("repel fixture"), + )), + check_failure: None, + failure: None, + }; + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Path(entry.clone()), + kind: SourceKind::Opy, + ..SessionConfig::default() + }, + Box::new(provider), + ) + .expect("provider session"); + // A compile establishes the canonical snapshot semantic queries serve. + let compile = session.compile(); + assert!(compile.ok, "provider compile: {:?}", compile.diagnostics); + let mut service = ToolService::new(&mut session).expect("service loads"); + let ToolResponse::Ok { result } = service.handle(&ToolRequest::Rules { + name: None, + rule: None, + file: None, + max: None, + content: true, + }) else { + panic!("rules with content failed"); + }; + assert_eq!( + result["unavailable"], + serde_json::json!({"names": "opy-rs"}) + ); + assert!(result.get("names").is_none()); + + let rules = result["rules"].as_array().expect("rules"); + assert_eq!(rules.len(), 1); + let actions = rules[0]["content"]["actions"].as_array().unwrap(); + let impulses = actions + .iter() + .filter(|action| { + action["call"] == "applyImpulse" && action["args"][2].as_f64() == Some(150.0) + }) + .count(); + assert_eq!(impulses, 2, "two strength-150 impulses: {actions:?}"); + let stunned = serde_json::json!({"enum": "Status", "member": "STUNNED"}); + let stuns = actions + .iter() + .filter(|action| { + action["call"] == "setStatusEffect" + && action["args"][2] == stunned + && action["args"][3].as_f64() == Some(0.75) + }) + .count(); + assert_eq!(stuns, 2, "two 0.75s stuns: {actions:?}"); + cleanup(dir); +} diff --git a/schemas/wright-agent-v1.schema.json b/schemas/wright-agent-v1.schema.json index fd89d960..2667ab54 100644 --- a/schemas/wright-agent-v1.schema.json +++ b/schemas/wright-agent-v1.schema.json @@ -392,7 +392,11 @@ "required": ["rules", "selection"], "properties": { "rules": { "type": "array", "items": { "$ref": "#/$defs/RuleSummary" } }, - "selection": { "$ref": "#/$defs/SelectionSummary" } + "selection": { "$ref": "#/$defs/SelectionSummary" }, + "unavailable": { + "type": "object", + "description": "ADR-0024: result members the result should carry but cannot for this input. `names` names the owner that must serve target-language spellings (today `opy-rs` for an OverPy input, until its provider capability exists); raw Workshop results carry no `unavailable`." + } }, "additionalProperties": true }, From c29a81a247a4932fe971ef888555ced2c11eea54 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:18:27 +0800 Subject: [PATCH 4/7] fix(bench): cache program-calls rule content by bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruleContent cache key now includes maxRules so two checks in one scenario with different bounds cannot share a truncated read. New tests pin that a withheld selection fails the check instead of passing on half the program, and that a Wright unable to serve rule content reports unavailable rather than a pass — whether it ignores the field or refuses the request. --- benchmarks/agent/bench_grade.py | 8 ++++--- benchmarks/agent/test_agent_bench.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/benchmarks/agent/bench_grade.py b/benchmarks/agent/bench_grade.py index a30bbda5..82e4405e 100644 --- a/benchmarks/agent/bench_grade.py +++ b/benchmarks/agent/bench_grade.py @@ -162,10 +162,12 @@ def program_calls(check: dict, entry: Path, wright: str, state: dict) -> dict: path = compiled_path(state, source) if path is None: return check_result(check, False, f"no compiled text from {source}", unavailable=source == "oracle" and not oracle_available()) + max_rules = check.get("maxRules", 200) cache = state.setdefault("ruleContent", {}) - if source not in cache: - cache[source] = rule_content(wright, path, check.get("maxRules", 200)) - response = cache[source] + key = (source, max_rules) + if key not in cache: + cache[key] = rule_content(wright, path, max_rules) + response = cache[key] if "error" in response: # A Wright without the rules `content` field cannot grade this: unavailable, never a pass. return check_result(check, False, f"rule content unavailable: {response['error'].get('message', response['error'])}", unavailable=True) diff --git a/benchmarks/agent/test_agent_bench.py b/benchmarks/agent/test_agent_bench.py index 86d56205..984a0d22 100644 --- a/benchmarks/agent/test_agent_bench.py +++ b/benchmarks/agent/test_agent_bench.py @@ -396,6 +396,37 @@ def test_program_calls_alternatives_and_event_restriction(self): def test_program_calls_reads_the_upstream_oracle_output(self): self.assertEqual(self.program_calls("repel.opy", list(self.REPEL_CHECKS), source="oracle"), [True, True]) + def test_program_calls_fails_an_incomplete_read(self): + """`withheld` rules mean the grader saw only part of the program: the check fails, it never passes on half.""" + entry = agent_bench.HERE / "fixtures/program-calls/repel.opy" + scratch = self.out / "program-calls-withheld" + state = {"scratch": scratch, "authorities": bench_grade.authorities(WRIGHT, entry, scratch)} + partial = {"result": {"rules": [{"content": {"event": {"id": "eachPlayer"}, "conditions": [], + "actions": [{"call": "applyImpulse", "args": [None, None, 150]}]}}], + "selection": {"withheld": 3}}} + check = {"id": "c", "kind": "program-calls", "call": "applyImpulse", "args": {"2": 150}, "min": 2} + with patch.object(bench_grade, "rule_content", return_value=partial): + result = bench_grade.run_check(check, entry.parent, entry, WRIGHT, state) + self.assertFalse(result["passed"]) + self.assertIn("withheld", result["detail"]) + + def test_program_calls_is_unavailable_when_wright_cannot_serve_content(self): + """A Wright without the rules `content` field cannot grade: `unavailable`, never a pass (ADR-0024).""" + entry = agent_bench.HERE / "fixtures/program-calls/repel.opy" + scratch = self.out / "program-calls-unavailable" + state = {"scratch": scratch, "authorities": bench_grade.authorities(WRIGHT, entry, scratch)} + check = {"id": "c", "kind": "program-calls", "call": "applyImpulse"} + # A pre-ADR-0024 wright ignores `content` and returns the bare rule list. + with patch.object(bench_grade, "rule_content", return_value={"result": [{"id": 0, "name": "r"}]}): + bare = bench_grade.run_check(check, entry.parent, entry, WRIGHT, state) + self.assertFalse(bare["passed"]) + self.assertTrue(bare["unavailable"]) + # A wright that refuses the request errors the same way. + with patch.object(bench_grade, "rule_content", return_value={"error": {"code": "invalid-request", "message": "unknown field"}}): + refused = bench_grade.run_check(check, entry.parent, entry, WRIGHT, state) + self.assertFalse(refused["passed"]) + self.assertTrue(refused["unavailable"]) + def test_pattern_matching_compares_numbers_by_value_and_never_confuses_booleans(self): self.assertTrue(bench_grade.pattern_matches(0.75, 0.75)) self.assertTrue(bench_grade.pattern_matches(150, 150.0)) From cf1e61a259ff1a37a599b66a636690d8226cd5f0 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:18:28 +0800 Subject: [PATCH 5/7] chore(bench): re-baseline overwatch-ai-pve for the span-pruning diagnostic change workshop-rs 1.16 prunes provenance spans that do not resolve in an attached source, so the source-map-span-overflow warning no longer enters the cli envelopes: cli:check drops 514B/128.5T/diagnostics:1 to 193B/48.2T/diagnostics:0 (the pre-#619 size), and cli:lint, cli:analyze, cli:compile record diagnostics:0. compile emittedBytes 341401->341411 and small envelope shifts ride along as in-band accepted values from the same run. --- benchmarks/metrics/baseline-v1.json | 112 ++++++++++++++-------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/benchmarks/metrics/baseline-v1.json b/benchmarks/metrics/baseline-v1.json index 672e8563..1696c5f9 100644 --- a/benchmarks/metrics/baseline-v1.json +++ b/benchmarks/metrics/baseline-v1.json @@ -325,7 +325,7 @@ "kind": "opy", "metrics": { "agent:analyze": { - "bytes": 421138, + "bytes": 426553, "counts": { "diagnostics": 0, "facts.persistentObjects": 8, @@ -338,11 +338,11 @@ "program.rules": 369, "program.subroutines": 70 }, - "latencyMs": 184.7, - "tokens": 105284.5 + "latencyMs": 201.8, + "tokens": 106638.2 }, "agent:analyze?brief": { - "bytes": 1449, + "bytes": 1451, "counts": { "counts.elements": 16647, "counts.persistentObjects": 8, @@ -351,29 +351,29 @@ "counts.symbols": 576, "items": 5 }, - "latencyMs": 175.7, - "tokens": 362.2 + "latencyMs": 189.8, + "tokens": 362.8 }, "agent:callGraph": { "bytes": 8627, "counts": { "items": 95 }, - "latencyMs": 24.3, + "latencyMs": 24.1, "tokens": 2156.8 }, "agent:capabilities": { - "bytes": 1418, + "bytes": 1459, "counts": {}, "latencyMs": 0.1, - "tokens": 354.5 + "tokens": 364.8 }, "agent:cfg": { "bytes": 231, "counts": { "blocks": 2 }, - "latencyMs": 24.0, + "latencyMs": 24.8, "tokens": 57.8 }, "agent:check": { @@ -381,17 +381,17 @@ "counts": { "diagnostics": 0 }, - "latencyMs": 19.2, + "latencyMs": 24.7, "tokens": 34.5 }, "agent:compile": { - "bytes": 365866, + "bytes": 365876, "counts": { "diagnostics": 0, - "emittedBytes": 341401 + "emittedBytes": 341411 }, - "latencyMs": 676.6, - "tokens": 91466.5 + "latencyMs": 514.1, + "tokens": 91469.0 }, "agent:costEstimate": { "bytes": 13068, @@ -402,7 +402,7 @@ "programRules": 369, "waitActions": 233 }, - "latencyMs": 612.4, + "latencyMs": 453.9, "tokens": 3267.0 }, "agent:findings": { @@ -410,11 +410,11 @@ "counts": { "items": 55 }, - "latencyMs": 24.9, + "latencyMs": 28.1, "tokens": 5853.0 }, "agent:inspect": { - "bytes": 314414, + "bytes": 323908, "counts": { "diagnostics": 0, "program.files": 313, @@ -427,19 +427,19 @@ "rules": 369, "symbols": 576 }, - "latencyMs": 52.8, - "tokens": 78603.5 + "latencyMs": 53.7, + "tokens": 80977.0 }, "agent:inspect?brief": { - "bytes": 1133, + "bytes": 1226, "counts": { "counts.references": 2024, "counts.rules": 369, "counts.symbols": 576, "items": 5 }, - "latencyMs": 41.8, - "tokens": 283.2 + "latencyMs": 42.3, + "tokens": 306.5 }, "agent:lint": { "bytes": 24324, @@ -447,7 +447,7 @@ "finding.ongoing-condition-hot-path": 55, "items": 55 }, - "latencyMs": 24.9, + "latencyMs": 27.9, "tokens": 6081.0 }, "agent:lint?brief": { @@ -458,7 +458,7 @@ "counts.skipped": 0, "items": 5 }, - "latencyMs": 25.4, + "latencyMs": 28.1, "tokens": 563.0 }, "agent:lintRules": { @@ -466,7 +466,7 @@ "counts": { "rules": 6 }, - "latencyMs": 24.6, + "latencyMs": 24.5, "tokens": 2032.8 }, "agent:persistentObjects": { @@ -476,7 +476,7 @@ "item.hud-text": 7, "items": 8 }, - "latencyMs": 24.6, + "latencyMs": 24.7, "tokens": 678.2 }, "agent:project": { @@ -490,7 +490,7 @@ "subroutines": 70, "symbols": 576 }, - "latencyMs": 174.5, + "latencyMs": 172.2, "tokens": 61.8 }, "agent:references": { @@ -501,7 +501,7 @@ "reference.read": 1, "reference.write": 1 }, - "latencyMs": 24.5, + "latencyMs": 29.3, "tokens": 103.5 }, "agent:rules": { @@ -509,7 +509,7 @@ "counts": { "items": 369 }, - "latencyMs": 30.1, + "latencyMs": 42.3, "tokens": 17553.2 }, "agent:symbols": { @@ -521,14 +521,14 @@ "symbol.rule": 369, "symbol.subroutine": 70 }, - "latencyMs": 44.7, + "latencyMs": 48.7, "tokens": 25300.2 }, "agent:targetMetadata": { - "bytes": 15527, + "bytes": 15550, "counts": {}, "latencyMs": 0.7, - "tokens": 3881.8 + "tokens": 3887.5 }, "agent:usage": { "bytes": 100, @@ -538,13 +538,13 @@ "rules": 2, "writes": 1 }, - "latencyMs": 24.4, + "latencyMs": 24.1, "tokens": 25.0 }, "cli:analyze": { - "bytes": 1022602, + "bytes": 1021677, "counts": { - "diagnostics": 1, + "diagnostics": 0, "facts.persistentObjects": 8, "facts.risks": 55, "facts.rules": 369, @@ -555,11 +555,11 @@ "program.rules": 369, "program.subroutines": 70 }, - "latencyMs": 12317.0, - "tokens": 255650.5 + "latencyMs": 5910.6, + "tokens": 255419.2 }, "cli:analyze?brief": { - "bytes": 2634, + "bytes": 2636, "counts": { "counts.elements": 16647, "counts.persistentObjects": 8, @@ -568,31 +568,31 @@ "counts.symbols": 576, "items": 5 }, - "tokens": 658.5 + "tokens": 659.0 }, "cli:check": { - "bytes": 514, + "bytes": 193, "counts": { - "diagnostics": 1 + "diagnostics": 0 }, - "latencyMs": 8325.6, - "tokens": 128.5 + "latencyMs": 5857.6, + "tokens": 48.2 }, "cli:compile": { - "bytes": 366271, + "bytes": 365960, "counts": { - "diagnostics": 1, - "emittedBytes": 341401 + "diagnostics": 0, + "emittedBytes": 341411 }, - "latencyMs": 12549.8, - "tokens": 91567.8 + "latencyMs": 6091.3, + "tokens": 91490.0 }, "cli:inspect": { "bytes": 535, "counts": { "diagnostics": 1 }, - "latencyMs": 249.7, + "latencyMs": 251.9, "tokens": 133.8 }, "cli:inspect?brief": { @@ -606,24 +606,24 @@ "tokens": 184.5 }, "cli:lint": { - "bytes": 38786, + "bytes": 38465, "counts": { - "diagnostics": 1, + "diagnostics": 0, "lint.finding.ongoing-condition-hot-path": 55, "lint.items": 55 }, - "latencyMs": 12108.4, - "tokens": 9696.5 + "latencyMs": 5801.9, + "tokens": 9616.2 }, "cli:lint?brief": { - "bytes": 3830, + "bytes": 3509, "counts": { "counts.findings.total": 55, "counts.rules": 6, "counts.skipped": 0, "items": 5 }, - "tokens": 957.5 + "tokens": 877.2 } }, "status": "ok" From d1294673f913725b8b0ebb7491886b97d335475b Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:23:01 +0800 Subject: [PATCH 6/7] test(driver): assert every rules statement resolves its span path #624 requires the driver test to prove resolved span paths on every statement, not a single one; the selected rule's conditions and actions all carry declarations-rules.ws. --- crates/wright-driver/tests/service.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/wright-driver/tests/service.rs b/crates/wright-driver/tests/service.rs index 1efa572e..69f6cff0 100644 --- a/crates/wright-driver/tests/service.rs +++ b/crates/wright-driver/tests/service.rs @@ -3301,6 +3301,16 @@ fn rules_content_serves_bounded_canonical_call_trees() { let by_id = result_of(&mut service, &request(Some(Address::Id(1)), None)); assert_eq!(by_id["rules"][0]["content"], rule["content"]); + // Every statement carries a resolved span path — never read from text. + for statement in rule["content"]["conditions"] + .as_array() + .unwrap() + .iter() + .chain(rule["content"]["actions"].as_array().unwrap()) + { + assert_eq!(statement["span"]["path"], "declarations-rules.ws"); + } + // `max` bounds the rule count and reports what it withheld; a selected // rule's content is never truncated. let bounded = result_of(&mut service, &request(None, Some(1))); From 7938cc1be59eb58fbe1708c0080d74d95abdd412 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:45:48 +0800 Subject: [PATCH 7/7] fix(bench): skip disabled statements and rules in program-calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit count_calls counted calls inside Disabled() actions, disabled conditions, and disabled rules — none of which run — so an agent could satisfy a check with dead code. Rules with content.disabled, and statements carrying the rule-content-v1 disabled marker, are now skipped; a test pins one disabled action, one disabled condition, and one disabled rule not counting. --- benchmarks/agent/bench_grade.py | 8 +++++--- benchmarks/agent/test_agent_bench.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/benchmarks/agent/bench_grade.py b/benchmarks/agent/bench_grade.py index 82e4405e..cf56bd02 100644 --- a/benchmarks/agent/bench_grade.py +++ b/benchmarks/agent/bench_grade.py @@ -140,13 +140,15 @@ def call_nodes(node): def count_calls(rules: list[dict], check: dict) -> int: - """Calls matching `call` and the positional `args` patterns, across every rule (or those with the `event` id).""" + """Calls matching `call` and the positional `args` patterns, across every enabled rule (or those with the `event` id).""" count = 0 for rule in rules: content = rule["content"] - if "event" in check and content["event"]["id"] != check["event"]: + if content.get("disabled") or "event" in check and content["event"]["id"] != check["event"]: continue - statements = [condition["value"] for condition in content["conditions"]] + content["actions"] + statements = [ + condition["value"] for condition in content["conditions"] if not condition.get("disabled") + ] + [action for action in content["actions"] if not action.get("disabled")] for statement in statements: for node in call_nodes(statement): if node["call"] == check["call"] and all( diff --git a/benchmarks/agent/test_agent_bench.py b/benchmarks/agent/test_agent_bench.py index 984a0d22..4e32bdde 100644 --- a/benchmarks/agent/test_agent_bench.py +++ b/benchmarks/agent/test_agent_bench.py @@ -410,6 +410,26 @@ def test_program_calls_fails_an_incomplete_read(self): self.assertFalse(result["passed"]) self.assertIn("withheld", result["detail"]) + def test_program_calls_ignores_disabled_calls_and_rules(self): + """A disabled statement or rule never runs, so its calls cannot satisfy a check.""" + entry = agent_bench.HERE / "fixtures/program-calls/repel.opy" + scratch = self.out / "program-calls-disabled" + state = {"scratch": scratch, "authorities": bench_grade.authorities(WRIGHT, entry, scratch)} + impulse = {"call": "applyImpulse", "args": [None, None, 150]} + rules = [ + {"content": {"event": {"id": "eachPlayer"}, "disabled": False, + "conditions": [{"value": impulse, "disabled": True}], + "actions": [impulse, {**impulse, "disabled": True}]}}, + {"content": {"event": {"id": "eachPlayer"}, "disabled": True, + "conditions": [], "actions": [impulse, impulse]}}, + ] + response = {"result": {"rules": rules, "selection": {"withheld": 0}}} + check = {"id": "c", "kind": "program-calls", "call": "applyImpulse", "args": {"2": 150}, "min": 2} + with patch.object(bench_grade, "rule_content", return_value=response): + result = bench_grade.run_check(check, entry.parent, entry, WRIGHT, state) + self.assertFalse(result["passed"]) + self.assertIn("1 matching", result["detail"]) + def test_program_calls_is_unavailable_when_wright_cannot_serve_content(self): """A Wright without the rules `content` field cannot grade: `unavailable`, never a pass (ADR-0024).""" entry = agent_bench.HERE / "fixtures/program-calls/repel.opy"