From 88343c6ba95dd8d9c838ec05d7f9e15ba3a1e2a8 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:50:22 +0800 Subject: [PATCH 1/6] deps: pin workshop-rs to the rev carrying Program::set_file_source The mapped-source retention in #583 needs to attach authored text to a provider map's file entries; the pin targets the workshop-rs commit adding that API and retargets to crates.io once it ships. The pinned rev also classifies near-miss settings spellings as catalog-spelling-near-miss (workshop-rs#406): wright maps the new residual to unsupported like the other uncatalogued classes, and the raw-setting test names the new code. --- Cargo.lock | 5 ++--- Cargo.toml | 7 +++++++ crates/wright-driver/src/workshop_provider.rs | 5 ++++- crates/wright-driver/tests/driver.rs | 4 ++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 965d6b01..40fa494e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2254,9 +2254,8 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "workshop-rs" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22194b44b067d9a48bfb6b6715cc5de167999ad505996e69e2a9af283abf4e31" +version = "1.11.0" +source = "git+https://github.com/wrightkit/workshop-rs.git?rev=6a97b5c#6a97b5c2c9a265d9417f54d3778dd68f7ad3e1b0" dependencies = [ "aho-corasick", "serde", diff --git a/Cargo.toml b/Cargo.toml index cb06878d..d1bbe79b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,3 +55,10 @@ debug = 1 [profile.test.package."*"] debug = false + +# Canonical Workshop core. The rev is pinned to the workshop-rs commit that +# adds `Program::set_file_source` (wright#583, workshop-rs +# feat/program-set-file-source); retarget to the crates.io release once the +# API ships in a published version. +[patch.crates-io] +workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "6a97b5c" } diff --git a/crates/wright-driver/src/workshop_provider.rs b/crates/wright-driver/src/workshop_provider.rs index a527a427..e3bd13da 100644 --- a/crates/wright-driver/src/workshop_provider.rs +++ b/crates/wright-driver/src/workshop_provider.rs @@ -93,7 +93,10 @@ pub fn status_for_classification( | workshop_rs::rules::ResidualClassification::SourceDeclaredVariable => Status::Partial, workshop_rs::rules::ResidualClassification::ProducerExtension | workshop_rs::rules::ResidualClassification::LegacyOpaque - | workshop_rs::rules::ResidualClassification::UnresolvedIdentifier => Status::Unsupported, + | workshop_rs::rules::ResidualClassification::UnresolvedIdentifier + | workshop_rs::rules::ResidualClassification::CatalogSpellingNearMiss => { + Status::Unsupported + } } } diff --git a/crates/wright-driver/tests/driver.rs b/crates/wright-driver/tests/driver.rs index c1f9249a..406c5396 100644 --- a/crates/wright-driver/tests/driver.rs +++ b/crates/wright-driver/tests/driver.rs @@ -117,12 +117,12 @@ fn workshop_raw_setting_residual_suggests_the_canonical_spelling() { }; assert_eq!( message("workshop.raw-setting.mode-nmae"), - "Workshop construct 'Mode Nmae' is partially supported (project-defined-construct) \ + "Workshop construct 'Mode Nmae' is unsupported (catalog-spelling-near-miss) \ (did you mean 'Mode Name'?)" ); assert_eq!( message("workshop.raw-setting.enabled-mpas"), - "Workshop construct 'Enabled Mpas' is partially supported (project-defined-construct) \ + "Workshop construct 'Enabled Mpas' is unsupported (catalog-spelling-near-miss) \ (did you mean 'enabled maps'?)" ); assert_eq!( From bf5881dc95cbdbb342e97557e5509d3af1c96b52 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:50:38 +0800 Subject: [PATCH 2/6] fix(driver): validate and apply lint fixes through the OPY provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider-mapped programs registered their file table but retained no source text, so lint fixes could not resolve authored extents, and the dead-branch scanner only knew Workshop keywords — an OPY elif/else/dedent boundary produced fix: null. The session now retains each mapped member's text after SourceMap::apply (Program::set_file_source), and dead_branch_fix prefers the marker actions' authored spans (if/elif/else keywords, End at the dedent) with the Workshop text scan as fallback for unmarked programs. Materialized mapped edits name the member's file:// URI under the authored text's identity and validate through providerValidateEdits + check; --write resolves URIs before replacing files. Unmapped artifacts still carry no fix. Fixes #583 --- .../wright-analyzer/src/canonical/analysis.rs | 37 ++- crates/wright-driver/src/edit.rs | 12 +- crates/wright-driver/src/fix.rs | 61 ++-- crates/wright-driver/src/service.rs | 178 +---------- crates/wright-driver/src/session.rs | 12 + crates/wright-driver/src/session/edit.rs | 288 +++++++++++------- crates/wright-driver/src/source_provider.rs | 190 ++++++++++++ crates/wright-driver/tests/source_provider.rs | 174 +++++++++++ 8 files changed, 643 insertions(+), 309 deletions(-) diff --git a/crates/wright-analyzer/src/canonical/analysis.rs b/crates/wright-analyzer/src/canonical/analysis.rs index f7715d95..f2e56fb7 100644 --- a/crates/wright-analyzer/src/canonical/analysis.rs +++ b/crates/wright-analyzer/src/canonical/analysis.rs @@ -387,8 +387,10 @@ fn duplicate_condition_findings( /// read from the tick snapshot (random numbers, advancing clocks, sampled /// server load) keep the branch reachable, so it stays. /// -/// Provenance alone cannot bound the branch: `Else If`, `Else`, and `End` -/// markers carry no action span, so the marker extents are derived +/// Branch extents come from marker provenance when the provider records it +/// (`Else If`/`Else` keywords and the `End` dedent position are authored +/// spans in the source language, #583) — [`marker_branch_range`]. Raw +/// Workshop markers carry no action span, so their extents are derived /// textually against the retained source — see [`SourceScan`]. fn dead_branch_fix( program: &Program, @@ -406,6 +408,10 @@ fn dead_branch_fix( .or_else(|| program.action_span(rule_id, action_id))? .file; let scan = SourceScan::new(program.source(file)?, file); + if let Some(range) = marker_branch_range(program, rule_id, rule, action_id, boundary, &scan) { + return dead_branch_span(&scan, range.start, range.end) + .map(|span| LintFix::RemoveDeadBranch { span }); + } let condition_end = expr_extent_end( condition, &mut |path| program.action_argument_value_span(rule_id, action_id, 0, path), @@ -437,6 +443,33 @@ fn dead_branch_fix( dead_branch_span(&scan, start, end).map(|span| LintFix::RemoveDeadBranch { span }) } +/// The dead branch's byte range taken from marker provenance: the provider's +/// recorded position for the dead `Else If` marker through the boundary +/// marker. Returns `None` unless the chain's `If` opener, the dead marker, +/// and the boundary marker all carry strictly increasing positions in the +/// scanned file — provenance where every marker inherits the same position +/// (or none) fails this check and the caller derives extents textually. +fn marker_branch_range( + program: &Program, + rule_id: RuleId, + rule: &Rule, + marker: usize, + boundary: usize, + scan: &SourceScan, +) -> Option> { + let chain = chain_if_index(&rule.actions, marker)?; + let start = program.action_span(rule_id, marker)?; + let end = program.action_span(rule_id, boundary)?; + let head = program.action_span(rule_id, chain)?; + if start.file != scan.file || end.file != scan.file || head.file != scan.file { + return None; + } + let head = scan.byte(head.start)?; + let start = scan.byte(start.start)?; + let end = scan.byte(end.start)?; + (head < start && start < end).then_some(start..end) +} + /// The branch-removal span between a dead `Else If` marker and the next /// chain marker, rounded to whole lines. `EditRange`s are half-open /// line/column positions applied as a standard text splice, so whole-line diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index a62ae404..aeda15d2 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -805,8 +805,10 @@ pub fn write_previews( expected.insert(edit.source.as_str(), edit.source_identity.as_str()); } for preview in previews { - let path = Path::new(&preview.source); - let bytes = std::fs::read(path).map_err(|error| { + // A provider-mapped edit names its source by `file://` URI (#583); + // `provider_uri_path` also passes plain path spellings through. + let path = PathBuf::from(crate::source_provider::provider_uri_path(&preview.source)); + let bytes = std::fs::read(&path).map_err(|error| { Diagnostic::error( "input-io", Stage::Discovery, @@ -830,8 +832,8 @@ pub fn write_previews( } let mut written = Vec::new(); for preview in previews { - let path = Path::new(&preview.source); - let temporary = temporary_path(path); + let path = PathBuf::from(crate::source_provider::provider_uri_path(&preview.source)); + let temporary = temporary_path(&path); std::fs::write(&temporary, &preview.new_text).map_err(|error| { Diagnostic::error( "output-io", @@ -839,7 +841,7 @@ pub fn write_previews( format!("cannot write '{}': {error}", temporary.display()), ) })?; - std::fs::rename(&temporary, path).map_err(|error| { + std::fs::rename(&temporary, &path).map_err(|error| { let _ = std::fs::remove_file(&temporary); Diagnostic::error( "output-io", diff --git a/crates/wright-driver/src/fix.rs b/crates/wright-driver/src/fix.rs index 8e9cb896..5062df07 100644 --- a/crates/wright-driver/src/fix.rs +++ b/crates/wright-driver/src/fix.rs @@ -3,8 +3,9 @@ //! edits against the authored source, carrying the input identity, that //! `validateEditTransaction` previews and `write_previews` applies. Fixes //! exist only for `exact` findings whose correction is unambiguous -//! (`duplicate-condition`, `repeated-value`), and only for raw Workshop -//! input whose spans map to authored source. +//! (`duplicate-condition`, `repeated-value`), and only for input whose +//! spans map to retained authored source: raw Workshop parses, and +//! provider-mapped programs whose file table retains source text (#583). use serde::Serialize; use serde_json::{Value as JsonValue, json}; @@ -38,9 +39,9 @@ pub fn attach_fixes( catalog: &Catalog, locale: &Locale, ) { - // Fixes rewrite authored Workshop source only: a provider artifact's - // spans index generated text, not the caller's files. - if loaded.provenance != Provenance::Source { + // An unmapped provider artifact's spans index generated text, not the + // caller's files; a mapped artifact's spans address authored members. + if loaded.provenance == Provenance::Unmapped { return; } for (finding, json) in findings.iter().zip(json.iter_mut()) { @@ -111,20 +112,40 @@ fn materialize( }) } -/// One plan span as a `SourceEdit` against the loaded input: the span must -/// map to the parsed text of file 0, the single authored source a raw -/// Workshop program has. +/// One plan span as a `SourceEdit` against the loaded input. For a raw +/// Workshop input the span must map to file 0's parsed text; for a +/// provider-mapped program the span's file indexes the source map's file +/// table — the edit names the member's `file://` URI and the identity of +/// the retained text, so `providerValidateEdits` and the write path +/// preconditions see the same bytes (#583). fn edit(loaded: &Loaded, span: Span, new_text: String) -> Option { - (span.file.index() == 0).then(|| SourceEdit { - edit_kind: "fix".to_string(), - source: loaded.input.display.clone(), - source_identity: loaded.input.identity.clone(), - range: EditRange { - start_line: span.start.line, - start_col: span.start.col, - end_line: span.end.line, - end_col: span.end.col, - }, - new_text, - }) + let range = EditRange { + start_line: span.start.line, + start_col: span.start.col, + end_line: span.end.line, + end_col: span.end.col, + }; + match loaded.provenance { + Provenance::Source => (span.file.index() == 0).then(|| SourceEdit { + edit_kind: "fix".to_string(), + source: loaded.input.display.clone(), + source_identity: loaded.input.identity.clone(), + range, + new_text, + }), + Provenance::Mapped => { + let member = loaded.source_files.get(span.file.index())?; + let (uri, _) = + crate::source_provider::provider_member(member, &loaded.input.cwd).ok()?; + let identity = crate::input_identity(loaded.program.source(span.file)?.text()); + Some(SourceEdit { + edit_kind: "fix".to_string(), + source: uri, + source_identity: identity, + range, + new_text, + }) + } + Provenance::Unmapped => None, + } } diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index e81535ef..56fef894 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -18,6 +18,7 @@ use serde_json::json; use crate::diag::Diagnostic; use crate::input; use crate::result::{AnalyzeResult, CheckResult, CompileResult, Envelope, InspectResult}; +use crate::source_provider::document_sources; use crate::{CompilerSession, Loaded, RESULT_CONTRACT}; use wright_analyzer::canonical::{ BLOCK_KINDS, ReferenceKind, SemanticIndex, SemanticService, SymbolKind, @@ -681,27 +682,11 @@ impl<'a> ToolService<'a> { return Ok(documents.clone()); } let loaded = self.snapshot(); - let mut set = wright_lpp::DocumentSet::new(); - for member in loaded.source_files.iter() { - let (uri, path) = provider_member(member, &loaded.input.cwd)?; - let text = std::fs::read_to_string(&path).map_err(|error| ToolErrorInfo { - code: "provider-document-unreadable".to_string(), - message: format!( - "cannot read loaded project member '{}' for the provider document set: {error}", - path.display() - ), - })?; - set.insert( - uri.clone(), - wright_lpp::Document { - uri, - language_id: language_id.to_string(), - version: 0, - text, - }, - ); - } - Ok(set) + crate::source_provider::provider_document_set( + &loaded.source_files, + &loaded.input.cwd, + language_id, + ) } /// The semantic service over the current snapshot — built in `adopt`. @@ -1531,95 +1516,6 @@ fn json_span_path(item: &serde_json::Value) -> Option<&str> { .and_then(serde_json::Value::as_str) } -/// The identity/version precondition view a defaulted `sources` mirrors -/// (#548): every document's own text under its URI. -fn document_sources( - documents: &wright_lpp::DocumentSet, -) -> std::collections::BTreeMap { - documents - .values() - .map(|document| (document.uri.clone(), document.text.clone())) - .collect() -} - -/// A loaded project member as `(file:// URI, absolute disk path)` (#548): a -/// URI spelling keeps its identity and converts back to its path; a bare -/// path absolutizes against the session's input cwd and converts to a -/// `file://` URI. A member that is not a disk file refuses — the defaulted -/// document set covers only sources the session read from disk. -fn provider_member( - member: &str, - cwd: &std::path::Path, -) -> Result<(String, std::path::PathBuf), ToolErrorInfo> { - // A Windows drive-absolute spelling (`C:\...`, `C:/...`) must be handled - // before URI parsing: `url::Url::parse` accepts it as a URI whose scheme - // is the drive letter, and `to_file_path` then refuses. The provider - // emits these spellings on Windows hosts; treat them as disk paths on - // every host so the refusal contract stays about URI kind, not host OS. - if let Some(pair) = windows_drive_member(member) { - return Ok(pair); - } - if let Ok(url) = url::Url::parse(member) { - let path = url.to_file_path().map_err(|()| ToolErrorInfo { - code: "provider-document-uri".to_string(), - message: format!( - "loaded project member '{member}' is not a disk file and cannot serve the \ - provider document set" - ), - })?; - return Ok((url.to_string(), path)); - } - let path = { - let path = std::path::PathBuf::from(member); - if path.is_absolute() { - path - } else { - cwd.join(path) - } - }; - url::Url::from_file_path(&path) - .map(|url| (url.to_string(), path)) - .map_err(|()| ToolErrorInfo { - code: "provider-document-uri".to_string(), - message: format!( - "loaded project member '{member}' cannot be expressed as a file:// URI" - ), - }) -} - -/// `C:\dir\file.opy` or `C:/dir/file.opy` → `(file:///C:/dir/file.opy, path)`. -/// The path is percent-encoded before parsing so literal `#`, `%`, `?`, and -/// friends are not reinterpreted as URL syntax — `Url::parse` alone would -/// treat `#` as a fragment delimiter and `%xx` as existing escapes. -fn windows_drive_member(member: &str) -> Option<(String, std::path::PathBuf)> { - const PATH_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS - .add(b' ') - .add(b'"') - .add(b'#') - .add(b'%') - .add(b'<') - .add(b'>') - .add(b'?') - .add(b'`') - .add(b'{') - .add(b'|') - .add(b'}') - .add(b'^'); - let bytes = member.as_bytes(); - let drive_absolute = bytes.len() > 2 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'\\' | b'/'); - if !drive_absolute { - return None; - } - let path = std::path::PathBuf::from(member); - let normalized = member.replace('\\', "/"); - let encoded = percent_encoding::utf8_percent_encode(&normalized, PATH_ENCODE); - let url = url::Url::parse(&format!("file:///{encoded}")).ok()?; - Some((url.to_string(), path)) -} - /// The hotpath measurement label for one request's dispatch. #[cfg_attr(not(feature = "hotpath"), allow(dead_code))] fn request_label(request: &ToolRequest) -> &'static str { @@ -1662,65 +1558,3 @@ fn diagnostics_list_code(result: &serde_json::Value, code: &str) -> bool { .any(|d| d.get("code").and_then(serde_json::Value::as_str) == Some(code)) }) } - -#[cfg(test)] -mod tests { - use super::provider_member; - use std::path::{Path, PathBuf}; - - #[test] - fn windows_drive_member_is_a_disk_path_not_a_uri_scheme() { - // `url::Url::parse` accepts `C:\...` as a URI with scheme `c`; the - // provider member helper must classify it as a filesystem path first. - let (uri, path) = provider_member("C:\\project\\main.opy", Path::new("/cwd")) - .expect("windows drive member resolves"); - assert_eq!(uri, "file:///C:/project/main.opy"); - assert_eq!(path, PathBuf::from("C:\\project\\main.opy")); - - let (uri, _) = provider_member("D:/work/lib.opy", Path::new("/cwd")) - .expect("forward-slash drive member resolves"); - assert_eq!(uri, "file:///D:/work/lib.opy"); - - // Literal `#`/`%`/`?` in the path are encoded, not parsed as URL - // syntax, and decode back to the same spelling. - for (member, uri) in [ - ("C:\\project#1\\main.opy", "file:///C:/project%231/main.opy"), - ( - "C:\\project%20name\\main.opy", - "file:///C:/project%2520name/main.opy", - ), - ] { - let (produced, _) = - provider_member(member, Path::new("/cwd")).expect("member resolves"); - assert_eq!(produced, uri); - let url = url::Url::parse(&produced).expect("uri parses"); - let decoded = percent_encoding::percent_decode_str(url.path()) - .decode_utf8() - .expect("utf8"); - assert_eq!(decoded, format!("/{}", member.replace('\\', "/"))); - } - } - - #[test] - fn file_uri_member_keeps_its_spelling() { - let (uri, path) = - provider_member("file:///project/main.opy", Path::new("/cwd")).expect("file URI"); - assert_eq!(uri, "file:///project/main.opy"); - assert_eq!(path, PathBuf::from("/project/main.opy")); - } - - #[test] - fn non_file_uri_member_refuses() { - let error = provider_member("untitled:main.opy", Path::new("/cwd")) - .expect_err("non-file scheme refuses"); - assert_eq!(error.code, "provider-document-uri"); - } - - #[test] - fn relative_member_resolves_against_input_cwd() { - let (uri, path) = provider_member("src/lib.opy", Path::new("/project")) - .expect("relative member resolves"); - assert_eq!(path, PathBuf::from("/project/src/lib.opy")); - assert_eq!(uri, "file:///project/src/lib.opy"); - } -} diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 528de9d3..0e7b2b5b 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -542,6 +542,18 @@ impl CompilerSession { Ok(()) => { provenance = Provenance::Mapped; source_files = map.files().iter().map(|f| provider_uri_path(f)).collect(); + // Retain each mapped member's authored text so source + // extents (lint fixes, previews) derive against what was + // actually written — the map alone carries only paths + // (#583). + for (index, member) in source_files.iter().enumerate() { + if let Ok(text) = std::fs::read_to_string(member) { + program.set_file_source( + workshop_rs::source::FileId::from_index(index), + text, + ); + } + } } Err(error) => self.diagnostics.push(Diagnostic::warning( "source-map-mismatch", diff --git a/crates/wright-driver/src/session/edit.rs b/crates/wright-driver/src/session/edit.rs index 2fac19f7..981e893e 100644 --- a/crates/wright-driver/src/session/edit.rs +++ b/crates/wright-driver/src/session/edit.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; -use super::CompilerSession; +use super::{CompilerSession, Provenance}; use crate::config::InputSpec; use crate::diag::{Diagnostic, Stage}; use crate::edit::{EditTransaction, EditValidation, RenameResult, RenameTarget, SemanticRename}; @@ -169,15 +169,11 @@ impl CompilerSession { } else { None }; - let outcomes = fix_outcomes( - &self.config, - &self.catalog, - &envelope.result.findings, - sources.as_ref(), - ) - .into_iter() - .map(|(outcome, _)| outcome) - .collect(); + let outcomes = self + .fix_outcomes(&envelope.result.findings, sources.as_ref()) + .into_iter() + .map(|(outcome, _)| outcome) + .collect(); envelope.result.fixes = Some(outcomes); return envelope; } @@ -201,14 +197,10 @@ impl CompilerSession { }); loop { let sources = current_sources(&self.config); - let candidate = fix_outcomes( - &self.config, - &self.catalog, - &envelope.result.findings, - sources.as_ref(), - ) - .into_iter() - .find(|(outcome, _)| outcome.status == LintFixStatus::Preview); + let candidate = self + .fix_outcomes(&envelope.result.findings, sources.as_ref()) + .into_iter() + .find(|(outcome, _)| outcome.status == LintFixStatus::Preview); let Some((outcome, resolved)) = candidate else { break; }; @@ -273,12 +265,7 @@ impl CompilerSession { .iter() .map(|outcome| (outcome.code.clone(), format!("{:?}", outcome.span))) .collect(); - for (outcome, _) in fix_outcomes( - &self.config, - &self.catalog, - &envelope.result.findings, - sources.as_ref(), - ) { + for (outcome, _) in self.fix_outcomes(&envelope.result.findings, sources.as_ref()) { if !seen.insert((outcome.code.clone(), format!("{:?}", outcome.span))) { continue; } @@ -295,6 +282,166 @@ impl CompilerSession { envelope.result.fixes = Some(applied); envelope } + + /// The validated disposition of every finding in `findings` that + /// carries a `fix` — in findings order. Each fix's transaction is + /// deserialized and validated: raw Workshop fixes through + /// `validate_transaction` (input identity, ranges, Workshop reparse), + /// provider-mapped fixes through the provider pipeline (#583). A fix + /// that fails deserializes or validates to a structured refusal. + fn fix_outcomes( + &self, + findings: &serde_json::Value, + sources: Option<&BTreeMap>, + ) -> Vec<(LintFixOutcome, Option)> { + let Some(findings) = findings.as_array() else { + return Vec::new(); + }; + let provider = self.provider_fix_context(); + findings + .iter() + .filter_map(|finding| self.fix_outcome(finding, sources, provider.as_ref())) + .collect() + } + + /// For a provider-mapped load, the document set and precondition + /// sources `providerValidateEdits` runs against — the members' current + /// disk text under their `file://` URIs, built once per fix batch. + /// `Some(Err)` refuses every fix in the batch with the member-read + /// diagnostic; `None` keeps the raw Workshop validation path. + fn provider_fix_context(&self) -> Option> { + let loaded = self.loaded.as_ref()?; + (loaded.provenance == Provenance::Mapped).then(|| { + crate::source_provider::provider_document_set( + &loaded.source_files, + &loaded.input.cwd, + crate::opy_provider::OPY_LANGUAGE_ID, + ) + .map(|documents| ProviderFixContext { + sources: crate::source_provider::document_sources(&documents), + project_root: crate::source_provider::provider_project_root(&loaded.input.root), + documents, + }) + .map_err(|info| Diagnostic::error(info.code, Stage::Discovery, info.message)) + }) + } + + /// Validate one mapped-source fix transaction through the provider + /// pipeline: Wright's preconditions, `lpp/validateEdits` per edited + /// document, then `lpp/check` over the edited project (#583). + fn provider_fix_validation( + &self, + context: &ProviderFixContext, + transaction: &EditTransaction, + ) -> Result<(Vec, Vec), Vec> { + let request = crate::provider_edit::ProviderValidateRequest { + documents: context.documents.clone(), + transaction: transaction.clone(), + sources: context.sources.clone(), + project_root: context.project_root.clone(), + }; + let mutation = self.run_provider_flow( + crate::opy_provider::OPY_LANGUAGE_ID, + &wright_lpp::ClientInfo { + name: wright_lpp::LPP_CLIENT_NAME.to_string(), + version: crate::result::DRIVER_VERSION.to_string(), + }, + |provider| crate::provider_edit::validate_transaction(provider, &request), + ); + if mutation.ok { + Ok((mutation.preview.unwrap_or_default(), mutation.diagnostics)) + } else { + Err(mutation.diagnostics) + } + } + + fn fix_outcome( + &self, + finding: &serde_json::Value, + sources: Option<&BTreeMap>, + provider: Option<&Result>, + ) -> Option<(LintFixOutcome, Option)> { + let fix = finding.get("fix")?; + if !fix.is_object() { + return None; + } + let outcome = |status, preview, diagnostics| LintFixOutcome { + code: finding["code"].as_str().unwrap_or_default().to_string(), + kind: fix["kind"].as_str().unwrap_or_default().to_string(), + summary: fix["summary"].as_str().unwrap_or_default().to_string(), + span: finding.get("span").cloned(), + status, + preview, + diagnostics, + }; + let transaction = + match serde_json::from_value::(fix["transaction"].clone()) { + Ok(transaction) => transaction, + Err(error) => { + return Some(( + outcome( + LintFixStatus::Refused, + None, + vec![Diagnostic::error( + "edit-invalid-transaction", + Stage::Discovery, + format!("the finding's fix transaction is malformed: {error}"), + )], + ), + None, + )); + } + }; + let validated = match provider { + Some(Err(diagnostic)) => Err(vec![diagnostic.clone()]), + Some(Ok(context)) => self + .provider_fix_validation(context, &transaction) + .map(|(previews, diagnostics)| (previews, diagnostics, Some(&context.sources))), + None => { + let validation = crate::edit::validate_transaction( + &self.config, + &self.catalog, + sources, + &transaction, + ); + if validation.ok { + Ok(( + validation.preview.unwrap_or_default(), + validation.diagnostics, + sources, + )) + } else { + Err(validation.diagnostics) + } + } + }; + let (previews, diagnostics, originals) = match validated { + Ok(validated) => validated, + Err(diagnostics) => { + return Some((outcome(LintFixStatus::Refused, None, diagnostics), None)); + } + }; + let rendered = previews + .iter() + .map(|preview| LintFixPreview { + // A mapped edit's `file://` URI displays as the same member + // path spelling the finding's `span.path` resolves to. + source: crate::source_provider::provider_uri_path(&preview.source), + original: originals + .and_then(|sources| sources.get(&preview.source)) + .cloned() + .unwrap_or_default(), + new_text: preview.new_text.clone(), + }) + .collect(); + Some(( + outcome(LintFixStatus::Preview, Some(rendered), diagnostics), + Some(ResolvedFix { + transaction, + previews, + }), + )) + } } /// A fix that validated: the deserialized transaction and the source @@ -305,92 +452,13 @@ struct ResolvedFix { previews: Vec, } -/// The validated disposition of every finding in `findings` that carries a -/// `fix` — in findings order. Each fix's transaction is deserialized and -/// validated through `validate_transaction` (input identity, ranges, -/// Workshop reparse); a fix that fails deserializes or validates to a -/// structured refusal. -fn fix_outcomes( - config: &crate::config::SessionConfig, - catalog: &workshop_rs::catalog::Catalog, - findings: &serde_json::Value, - sources: Option<&BTreeMap>, -) -> Vec<(LintFixOutcome, Option)> { - let Some(findings) = findings.as_array() else { - return Vec::new(); - }; - findings - .iter() - .filter_map(|finding| fix_outcome(config, catalog, finding, sources)) - .collect() -} - -fn fix_outcome( - config: &crate::config::SessionConfig, - catalog: &workshop_rs::catalog::Catalog, - finding: &serde_json::Value, - sources: Option<&BTreeMap>, -) -> Option<(LintFixOutcome, Option)> { - let fix = finding.get("fix")?; - if !fix.is_object() { - return None; - } - let outcome = |status, preview, diagnostics| LintFixOutcome { - code: finding["code"].as_str().unwrap_or_default().to_string(), - kind: fix["kind"].as_str().unwrap_or_default().to_string(), - summary: fix["summary"].as_str().unwrap_or_default().to_string(), - span: finding.get("span").cloned(), - status, - preview, - diagnostics, - }; - let transaction = match serde_json::from_value::(fix["transaction"].clone()) { - Ok(transaction) => transaction, - Err(error) => { - return Some(( - outcome( - LintFixStatus::Refused, - None, - vec![Diagnostic::error( - "edit-invalid-transaction", - Stage::Discovery, - format!("the finding's fix transaction is malformed: {error}"), - )], - ), - None, - )); - } - }; - let validation = crate::edit::validate_transaction(config, catalog, sources, &transaction); - if !validation.ok { - return Some(( - outcome(LintFixStatus::Refused, None, validation.diagnostics), - None, - )); - } - let previews = validation.preview.unwrap_or_default(); - let rendered = previews - .iter() - .map(|preview| LintFixPreview { - source: preview.source.clone(), - original: sources - .and_then(|sources| sources.get(&preview.source)) - .cloned() - .unwrap_or_default(), - new_text: preview.new_text.clone(), - }) - .collect(); - Some(( - outcome( - LintFixStatus::Preview, - Some(rendered), - validation.diagnostics, - ), - Some(ResolvedFix { - transaction, - previews, - }), - )) +/// The provider-side context a mapped fix batch validates against (#583): +/// the loaded project's document set, its precondition sources keyed by +/// URI, and the project root the session compiled under. +struct ProviderFixContext { + documents: wright_lpp::DocumentSet, + sources: BTreeMap, + project_root: Option, } /// A write run needs a disk-backed input: writing updates the file, but diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs index fa4711bc..478ba8e0 100644 --- a/crates/wright-driver/src/source_provider.rs +++ b/crates/wright-driver/src/source_provider.rs @@ -428,6 +428,140 @@ pub(crate) fn provider_uri_path(uri: &str) -> String { .unwrap_or_else(|| uri.to_string()) } +/// The identity/version precondition view a defaulted `sources` mirrors +/// (#548): every document's own text under its URI. +pub(crate) fn document_sources( + documents: &wright_lpp::DocumentSet, +) -> std::collections::BTreeMap { + documents + .values() + .map(|document| (document.uri.clone(), document.text.clone())) + .collect() +} + +/// The provider document set a mutation runs against (#548, #583): the +/// loaded project's members, each read from disk at `version` 0 and keyed +/// by `file://` URI, so the provider sees the same project the session +/// serves. A member that is not a readable disk file is a structured +/// refusal, never a silently partial set. +pub(crate) fn provider_document_set( + members: &[String], + cwd: &Path, + language_id: &str, +) -> Result { + let mut set = wright_lpp::DocumentSet::new(); + for member in members { + let (uri, path) = provider_member(member, cwd)?; + let text = std::fs::read_to_string(&path).map_err(|error| { + wright_analyzer::service::ErrorInfo { + code: "provider-document-unreadable".to_string(), + message: format!( + "cannot read loaded project member '{}' for the provider document set: {error}", + path.display() + ), + } + })?; + set.insert( + uri.clone(), + wright_lpp::Document { + uri, + language_id: language_id.to_string(), + version: 0, + text, + }, + ); + } + Ok(set) +} + +/// The `project_root` URI a provider request carries for a resolved input +/// root — the same directory-URI spelling `LppSourceProvider` sends. +pub(crate) fn provider_project_root(root: &Path) -> Option { + url::Url::from_directory_path(root) + .ok() + .map(|u| u.to_string()) +} + +/// A loaded project member as `(file:// URI, absolute disk path)` (#548): a +/// URI spelling keeps its identity and converts back to its path; a bare +/// path absolutizes against the session's input cwd and converts to a +/// `file://` URI. A member that is not a disk file refuses — the defaulted +/// document set covers only sources the session read from disk. +pub(crate) fn provider_member( + member: &str, + cwd: &std::path::Path, +) -> Result<(String, std::path::PathBuf), wright_analyzer::service::ErrorInfo> { + // A Windows drive-absolute spelling (`C:\...`, `C:/...`) must be handled + // before URI parsing: `url::Url::parse` accepts it as a URI whose scheme + // is the drive letter, and `to_file_path` then refuses. The provider + // emits these spellings on Windows hosts; treat them as disk paths on + // every host so the refusal contract stays about URI kind, not host OS. + if let Some(pair) = windows_drive_member(member) { + return Ok(pair); + } + if let Ok(url) = url::Url::parse(member) { + let path = url + .to_file_path() + .map_err(|()| wright_analyzer::service::ErrorInfo { + code: "provider-document-uri".to_string(), + message: format!( + "loaded project member '{member}' is not a disk file and cannot serve the \ + provider document set" + ), + })?; + return Ok((url.to_string(), path)); + } + let path = { + let path = std::path::PathBuf::from(member); + if path.is_absolute() { + path + } else { + cwd.join(path) + } + }; + url::Url::from_file_path(&path) + .map(|url| (url.to_string(), path)) + .map_err(|()| wright_analyzer::service::ErrorInfo { + code: "provider-document-uri".to_string(), + message: format!( + "loaded project member '{member}' cannot be expressed as a file:// URI" + ), + }) +} + +/// `C:\dir\file.opy` or `C:/dir/file.opy` → `(file:///C:/dir/file.opy, path)`. +/// The path is percent-encoded before parsing so literal `#`, `%`, `?`, and +/// friends are not reinterpreted as URL syntax — `Url::parse` alone would +/// treat `#` as a fragment delimiter and `%xx` as existing escapes. +fn windows_drive_member(member: &str) -> Option<(String, std::path::PathBuf)> { + const PATH_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}') + .add(b'^'); + let bytes = member.as_bytes(); + let drive_absolute = bytes.len() > 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/'); + if !drive_absolute { + return None; + } + let path = std::path::PathBuf::from(member); + let normalized = member.replace('\\', "/"); + let encoded = percent_encoding::utf8_percent_encode(&normalized, PATH_ENCODE); + let url = url::Url::parse(&format!("file:///{encoded}")).ok()?; + Some((url.to_string(), path)) +} + fn provider_position(pos: wright_lpp::Position) -> Position { Position { line: pos.line.saturating_add(1), @@ -440,6 +574,62 @@ mod tests { use super::*; use wright_lpp::{LppErrorKind, ProviderError}; + #[test] + fn windows_drive_member_is_a_disk_path_not_a_uri_scheme() { + // `url::Url::parse` accepts `C:\...` as a URI with scheme `c`; the + // provider member helper must classify it as a filesystem path first. + let (uri, path) = provider_member("C:\\project\\main.opy", Path::new("/cwd")) + .expect("windows drive member resolves"); + assert_eq!(uri, "file:///C:/project/main.opy"); + assert_eq!(path, PathBuf::from("C:\\project\\main.opy")); + + let (uri, _) = provider_member("D:/work/lib.opy", Path::new("/cwd")) + .expect("forward-slash drive member resolves"); + assert_eq!(uri, "file:///D:/work/lib.opy"); + + // Literal `#`/`%`/`?` in the path are encoded, not parsed as URL + // syntax, and decode back to the same spelling. + for (member, uri) in [ + ("C:\\project#1\\main.opy", "file:///C:/project%231/main.opy"), + ( + "C:\\project%20name\\main.opy", + "file:///C:/project%2520name/main.opy", + ), + ] { + let (produced, _) = + provider_member(member, Path::new("/cwd")).expect("member resolves"); + assert_eq!(produced, uri); + let url = url::Url::parse(&produced).expect("uri parses"); + let decoded = percent_encoding::percent_decode_str(url.path()) + .decode_utf8() + .expect("utf8"); + assert_eq!(decoded, format!("/{}", member.replace('\\', "/"))); + } + } + + #[test] + fn file_uri_member_keeps_its_spelling() { + let (uri, path) = + provider_member("file:///project/main.opy", Path::new("/cwd")).expect("file URI"); + assert_eq!(uri, "file:///project/main.opy"); + assert_eq!(path, PathBuf::from("/project/main.opy")); + } + + #[test] + fn non_file_uri_member_refuses() { + let error = provider_member("untitled:main.opy", Path::new("/cwd")) + .expect_err("non-file scheme refuses"); + assert_eq!(error.code, "provider-document-uri"); + } + + #[test] + fn relative_member_resolves_against_input_cwd() { + let (uri, path) = provider_member("src/lib.opy", Path::new("/project")) + .expect("relative member resolves"); + assert_eq!(path, PathBuf::from("/project/src/lib.opy")); + assert_eq!(uri, "file:///project/src/lib.opy"); + } + #[test] fn relative_entry_is_resolved_from_the_invocation_directory() { let target = SourceTarget::new(SourceLanguage::Opy, "src/main.opy", "/project"); diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs index c0b1e65c..dd0f26dd 100644 --- a/crates/wright-driver/tests/source_provider.rs +++ b/crates/wright-driver/tests/source_provider.rs @@ -366,6 +366,9 @@ fn mapped_lint_session( edit: impl FnOnce(&mut serde_json::Value), ) -> CompilerSession { let text = workshop_fixture("synthetic/control-flow"); + // The fake map's extracted spans carry this text's coordinates; write it + // as the member so retained-source validation sees in-range positions. + std::fs::write(dir.join("main.opy"), &text).expect("member source"); let provenance = mapped_provenance(&text, &dir.join("main.opy"), edit); let provider = RecordingProvider { target: Arc::new(Mutex::new(None)), @@ -547,6 +550,177 @@ fn nodes_without_an_authored_origin_stay_explicitly_unmapped() { cleanup(dir); } +/// `lint --fix` on a provider-mapped program materializes the dead-branch +/// removal against the authored OPY member — marker action spans give the +/// branch extent — and validates the transaction through the provider edit +/// pipeline (`lpp/validateEdits` + `lpp/check`) instead of the raw Workshop +/// reparse (#583). `--write` applies it and re-lints clean. +#[cfg(unix)] +#[test] +fn mapped_lint_fix_validates_and_writes_through_the_provider() { + use std::os::unix::fs::PermissionsExt; + + const PROVIDER: &str = r#"#!/usr/bin/env python3 +import json, sys +def reply(id, result=None, error=None): + message = {"jsonrpc": "2.0", "id": id} + message.update({"error": error} if error else {"result": result}) + print(json.dumps(message), flush=True) +for line in sys.stdin: + request = json.loads(line) + id, method = request["id"], request["method"] + if method == "lpp/initialize": + reply(id, {"protocolVersion": "1.0", "serverInfo": {"name": "fake", "version": "0"}, + "languages": [{"id": "opy", "extensions": ["opy"]}], + "capabilities": {"check": True, "compile": False, "reconstruct": False, "symbols": False, "definition": False, "references": False, "rename": False, "editValidation": True, "projectLoading": False}}) + elif method == "lpp/validateEdits": + reply(id, {"valid": True, "version": request["params"]["document"]["version"]}) + elif method == "lpp/check": + reply(id, {"documents": []}) + else: + reply(id, {}) +"#; + + // The authored OPY member the fake provider's source map points into. + const OPY: &str = "globalvar total\n\nrule \"dup\":\n @Event global\n if total == 0:\n total = 1\n elif total == 0:\n total = 3\n else:\n total = 4\n"; + const FIXED_OPY: &str = "globalvar total\n\nrule \"dup\":\n @Event global\n if total == 0:\n total = 1\n else:\n total = 4\n"; + + // The canonical artifact for each member state: the duplicate `elif` + // chain before the fix, the `else`-only chain after it — what a real + // provider emits when it recompiles the edited source. + const ARTIFACT: &str = "variables {\n global:\n 0: total\n}\nrule (\"dup\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(Compare(Global.total, ==, 0));\n Set Global Variable(total, 1);\n Else If(Compare(Global.total, ==, 0));\n Set Global Variable(total, 3);\n Else;\n Set Global Variable(total, 4);\n End;\n }\n}\n"; + const ARTIFACT_FIXED: &str = "variables {\n global:\n 0: total\n}\nrule (\"dup\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(Compare(Global.total, ==, 0));\n Set Global Variable(total, 1);\n Else;\n Set Global Variable(total, 4);\n End;\n }\n}\n"; + + /// Marker action spans the way the owning frontend records them (#583): + /// the `if` keyword anchors the chain head, `elif`/`else` keywords mark + /// their branches, and the chain `End` sits at the dedent/EOF boundary. + fn marker_map(text: &str, authored: &Path) -> wright_driver::SourceProvenance { + mapped_provenance(text, authored, |artifact| { + let spans = artifact["spans"].as_array_mut().expect("span list"); + // Every extracted entry carries Workshop-artifact coordinates; + // a fake provider's map authors only the marker/condition spans + // this test declares (`apply` rejects position-less entries). + spans.clear(); + let span = |sl: u32, sc: u32, el: u32, ec: u32| { + serde_json::json!({ + "file": 0, + "start": {"line": sl, "column": sc}, + "end": {"line": el, "column": ec}, + }) + }; + for (action, sl, sc, el, ec) in [ + (0usize, 5, 5, 5, 7), + (2, 7, 5, 7, 9), + (4, 9, 5, 9, 9), + (6, 11, 1, 11, 1), + ] { + spans.push(serde_json::json!({ + "node": "action", "rule": 0, "action": action, + "span": span(sl, sc, el, ec), + })); + } + // The dead `elif` condition's authored extent — the finding's + // span anchor and the fix's file resolution. + spans.push(serde_json::json!({ + "node": "action_argument", "rule": 0, "action": 2, "argument": 0, + "span": span(7, 10, 7, 20), + })); + }) + } + + // Re-derives its artifact from the member's current text: the edited + // member compiles to the `elif`-free chain on the post-write reload. + struct FixProvider { + entry: PathBuf, + } + + impl SourceProvider for FixProvider { + fn language(&self) -> SourceLanguage { + SourceLanguage::Opy + } + + fn compile( + &mut self, + _target: &SourceTarget, + ) -> Result { + let edited = !std::fs::read_to_string(&self.entry) + .unwrap() + .contains("elif"); + Ok(SourceCompilation { + workshop_text: Some(if edited { ARTIFACT_FIXED } else { ARTIFACT }.to_string()), + locale: None, + provenance: if edited { + wright_driver::SourceProvenance::Unmapped + } else { + marker_map(ARTIFACT, &self.entry) + }, + diagnostics: Vec::new(), + source_identity: None, + }) + } + } + + let (dir, entry) = temp_entry(); + std::fs::write(&entry, OPY).expect("entry source"); + let script = dir.join("fake-provider"); + std::fs::write(&script, PROVIDER).expect("provider script"); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Path(entry.clone()), + kind: SourceKind::Opy, + opy_provider: wright_driver::OpyProviderConfig::with_executable(script), + ..SessionConfig::default() + }, + Box::new(FixProvider { + entry: entry.clone(), + }), + ) + .expect("provider session"); + + let preview = session.lint_fix(false); + assert!(preview.ok, "preview lint: {:?}", preview.diagnostics); + let outcomes = preview.result.fixes.expect("fix outcomes"); + assert_eq!(outcomes.len(), 1, "{outcomes:?}"); + let outcome = &outcomes[0]; + assert_eq!( + outcome.status, + wright_driver::result::LintFixStatus::Preview + ); + assert_eq!(outcome.code, "duplicate-condition"); + let rendered = outcome.preview.as_ref().expect("validated preview"); + assert_eq!(rendered.len(), 1); + assert_eq!(rendered[0].new_text, FIXED_OPY); + assert_eq!(rendered[0].original, OPY); + assert_eq!( + rendered[0].source, + entry.display().to_string(), + "the preview names the authored member, not the artifact" + ); + assert_eq!( + std::fs::read_to_string(&entry).unwrap(), + OPY, + "a preview run writes nothing" + ); + + let written = session.lint_fix(true); + assert!(written.ok, "write lint: {:?}", written.diagnostics); + assert!( + written + .result + .fixes + .as_ref() + .expect("fix outcomes") + .iter() + .any(|outcome| outcome.status == wright_driver::result::LintFixStatus::Applied), + "the dead-branch fix applies: {:?}", + written.result.fixes + ); + assert_eq!(std::fs::read_to_string(&entry).unwrap(), FIXED_OPY); + cleanup(dir); +} + #[test] fn shape_mismatch_falls_back_to_unmapped_findings_with_a_diagnostic() { let (dir, entry) = temp_entry(); From c68d1ea1fbb3870caa498ae44a8d06e7eed5d462 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:56:53 +0800 Subject: [PATCH 3/6] docs(cli): lint fixes validate through the provider for mapped sources Refs #583 --- docs/cli/lint.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/cli/lint.md b/docs/cli/lint.md index f55cd22f..8927751c 100644 --- a/docs/cli/lint.md +++ b/docs/cli/lint.md @@ -229,8 +229,11 @@ The current fixable rules and their corrections: the evaluation count. Every offered fix edits only the reported spans, carries the input identity -as a precondition, and is validated by reparsing the edited source before -any write; a stale or malformed precondition refuses with -`edit-stale-source` (or the real parse/validation diagnostic) and writes -nothing. Fixes exist only on raw Workshop input — provider-backed findings -have spans in generated text and carry no `fix`. +as a precondition, and is validated before any write — a raw Workshop fix +reparses the edited source, while a provider-mapped fix goes through the +provider's own edit validation and project check. A stale or malformed +precondition refuses with `edit-stale-source` (or the real diagnostic) and +writes nothing. Provider-backed findings carry a `fix` when their source +map records authored positions — a mapped `duplicate-condition` edits the +authored `elif`/`else` chain directly; unmapped provider artifacts have +spans in generated text and carry no `fix`. From 580e294d43d763460b9c0412e46624acbea37a26 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:38:56 +0800 Subject: [PATCH 4/6] deps: repin workshop-rs to the rev that drops stale settings spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SourceMap::apply rebinds the program's file table, but the inertly carried settings tree kept spans in the parsed text's coordinate space. Once the session retains each mapped member's authored text (set_file_source), those stale positions fail Program::validate's bounds check — wright compile errors with invalid-span on any OPY project with a settings block (wright#583, bench seeds 'modify-opy-project', 'understand-opy-project', 'greenfield-opy-elimination-race', 'repair-opy-runaway-loop', 'renamed-callable-*', 'ana-paintball'). The map's contract is that nodes without an entry carry no span; workshop-rs 62b20df clears the settings tree's spans on apply since no map entry can express settings provenance. --- Cargo.lock | 2 +- Cargo.toml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40fa494e..73864276 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2255,7 +2255,7 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "workshop-rs" version = "1.11.0" -source = "git+https://github.com/wrightkit/workshop-rs.git?rev=6a97b5c#6a97b5c2c9a265d9417f54d3778dd68f7ad3e1b0" +source = "git+https://github.com/wrightkit/workshop-rs.git?rev=62b20df#62b20dfef31106ae55202cc9ce268b05df578065" dependencies = [ "aho-corasick", "serde", diff --git a/Cargo.toml b/Cargo.toml index d1bbe79b..7a47dde0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,8 +57,9 @@ debug = 1 debug = false # Canonical Workshop core. The rev is pinned to the workshop-rs commit that -# adds `Program::set_file_source` (wright#583, workshop-rs +# adds `Program::set_file_source` and drops stale settings spans when a +# source map rebinds the file table (wright#583, workshop-rs # feat/program-set-file-source); retarget to the crates.io release once the # API ships in a published version. [patch.crates-io] -workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "6a97b5c" } +workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "62b20df" } From 959577bf4c007283df6b1a94508ac74dd5f68dbd Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:52:27 +0800 Subject: [PATCH 5/6] deps: move workshop-rs to crates.io 1.12.0 The pin to a git rev of feat/program-set-file-source was a stopgap until Program::set_file_source shipped. workshop-rs 1.12.0 releases it together with the source-map fix that clears stale settings spans when apply rebinds the file table (wright#583), so the [patch.crates-io] section is removed. --- Cargo.lock | 5 +++-- Cargo.toml | 9 +-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73864276..38c500fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2254,8 +2254,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "workshop-rs" -version = "1.11.0" -source = "git+https://github.com/wrightkit/workshop-rs.git?rev=62b20df#62b20dfef31106ae55202cc9ce268b05df578065" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c71ac7ce770b486ecd6ae8b3cc67196ba464339159d9fd48a9de22257e9ccf" dependencies = [ "aho-corasick", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7a47dde0..7bac6e20 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.10.1" +workshop-rs = "1.12.0" libc = "0.2" serde = "1" serde_json = "1" @@ -56,10 +56,3 @@ debug = 1 [profile.test.package."*"] debug = false -# Canonical Workshop core. The rev is pinned to the workshop-rs commit that -# adds `Program::set_file_source` and drops stale settings spans when a -# source map rebinds the file table (wright#583, workshop-rs -# feat/program-set-file-source); retarget to the crates.io release once the -# API ships in a published version. -[patch.crates-io] -workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "62b20df" } From 43de95005a8fac35430f1e1d13e624ded920fd89 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:58:26 +0800 Subject: [PATCH 6/6] fix(driver): degrade provider-map span overflow instead of failing the load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retained-source attach turned provider spans that exceed the authored document into a hard invalid-span failure at validate() — released workshop-rs 1.12.0 attaches source without auditing stale spans (the audit lands post-1.12.0 in workshop-rs #410/#418). On real mapped projects every provider-backed lint/analyze/compile op collapsed to a parse-error envelope, which is what the metrics drift CI observed. Treat retained-source validation as a freshness audit rather than a load gate: on validate failure with any retained source, re-parse the artifact without retained text and continue with mapped positions plus a source-map-span-overflow warning. Relative map members now resolve against the session project root instead of the process cwd, and unreadable members warn instead of silently dropping. Refs #583 --- crates/wright-driver/src/session.rs | 93 +++++++++++++++---- crates/wright-driver/tests/source_provider.rs | 59 ++++++++++++ 2 files changed, 136 insertions(+), 16 deletions(-) diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 0e7b2b5b..83c01c24 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -537,6 +537,7 @@ impl CompilerSession { .map_err(|error| workshop_diag_for_provider_artifact(error, resolved, &[]))?; let mut provenance = Provenance::Unmapped; let mut source_files = vec![resolved.display.clone()]; + let mut retained_source = false; if let Some(map) = &source_map { match map.apply(&mut program) { Ok(()) => { @@ -545,15 +546,38 @@ impl CompilerSession { // Retain each mapped member's authored text so source // extents (lint fixes, previews) derive against what was // actually written — the map alone carries only paths - // (#583). + // (#583). Relative member spellings resolve against the + // session's project root, not the process working + // directory. + let mut unreadable = Vec::new(); for (index, member) in source_files.iter().enumerate() { - if let Ok(text) = std::fs::read_to_string(member) { - program.set_file_source( - workshop_rs::source::FileId::from_index(index), - text, - ); + let member_path = Path::new(member); + let member_path = if member_path.is_absolute() { + member_path.to_path_buf() + } else { + resolved.root.join(member_path) + }; + match std::fs::read_to_string(&member_path) { + Ok(text) => { + retained_source |= program.set_file_source( + workshop_rs::source::FileId::from_index(index), + text, + ); + } + Err(error) => unreadable.push(format!("{member} ({error})")), } } + if !unreadable.is_empty() { + self.diagnostics.push(Diagnostic::warning( + "source-map-member-unreadable", + Stage::Frontend, + format!( + "{} provider source-map member(s) could not be read, so their source extents are unavailable: {}", + unreadable.len(), + unreadable.join(", "), + ), + )); + } } Err(error) => self.diagnostics.push(Diagnostic::warning( "source-map-mismatch", @@ -565,17 +589,54 @@ impl CompilerSession { } } self.progress(ProgressEvent::new(ProgressPhase::Validation)); - program.validate().map_err(|error| { - workshop_diag_for_provider_artifact( - error, - resolved, - if provenance == Provenance::Mapped { - &source_files - } else { - &[] - }, + if let Err(error) = program.validate() { + if !retained_source { + return Err(workshop_diag_for_provider_artifact( + error, + resolved, + if provenance == Provenance::Mapped { + &source_files + } else { + &[] + }, + )); + } + // A provider map may carry spans that do not resolve inside the + // retained authored source (e.g. positions recorded against an + // expansion rather than the authored line). Retained-source + // validation is a freshness audit, not a load gate: re-parse the + // artifact without retained text so the run still serves — + // mapped positions survive, authored extents degrade (#583). + let mut unretained = workshop_rs::parser::parse_with_context( + &workshop_text, + &self.catalog, + &locale, + &*self.catalog, ) - })?; + .map_err(|error| workshop_diag_for_provider_artifact(error, resolved, &[]))?; + if let Some(map) = &source_map { + map.apply(&mut unretained).map_err(|apply_error| { + Diagnostic::error( + "source-map-mismatch", + Stage::Frontend, + format!( + "the provider source map does not match its Workshop artifact ({apply_error})" + ), + ) + })?; + } + unretained.validate().map_err(|error| { + workshop_diag_for_provider_artifact(error, resolved, &source_files) + })?; + program = unretained; + self.diagnostics.push(Diagnostic::warning( + "source-map-span-overflow", + Stage::Frontend, + format!( + "the provider source map carries spans outside its members' retained sources ({error}); authored source extents are unavailable" + ), + )); + } if self.config.profile != wright_transform::Profile::Off { self.progress(ProgressEvent::new(ProgressPhase::Lowering)); } diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs index dd0f26dd..7029c763 100644 --- a/crates/wright-driver/tests/source_provider.rs +++ b/crates/wright-driver/tests/source_provider.rs @@ -463,6 +463,65 @@ fn mapped_provider_project_reports_its_source_file_count() { cleanup(dir); } +/// 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). +#[test] +fn mapped_span_outside_retained_source_degrades_extents_not_the_load() { + let (dir, entry) = temp_entry(); + let mut session = mapped_lint_session(&dir, entry, |artifact| { + let node = artifact["spans"] + .as_array_mut() + .expect("spans") + .iter_mut() + .find(|node| node.get("span").is_some_and(|span| span.is_object())) + .expect("a node carrying a span"); + node["span"]["end"] = serde_json::json!({"line": 9999, "column": 1}); + }); + + let lint = session.lint(); + assert!(lint.ok, "mapped lint: {:?}", lint.diagnostics); + assert_eq!( + session.load().expect("loaded").provenance, + wright_driver::Provenance::Mapped + ); + assert!( + lint.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "source-map-span-overflow") + ); + cleanup(dir); +} + +/// A member the provider map names but no readable disk file serves no +/// retained source; the load warns instead of silently dropping it (#583). +#[test] +fn unreadable_mapped_member_warns_instead_of_silently_dropping() { + let (dir, entry) = temp_entry(); + let mut session = mapped_lint_session(&dir, entry, |artifact| { + artifact["files"] + .as_array_mut() + .expect("file table") + .push(serde_json::json!({ + "path": url::Url::from_file_path(dir.join("missing.opy")) + .expect("file URI") + .to_string(), + })); + }); + + let lint = session.lint(); + assert!(lint.ok, "mapped lint: {:?}", lint.diagnostics); + assert!( + lint.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "source-map-member-unreadable") + ); + cleanup(dir); +} + /// A conforming pre-1.4 provider allows one `lpp/initialize` per process, so /// the fallback after a refused 1.4 negotiation must use a restarted process. #[cfg(unix)]