1use std::path::Path;
11
12use perspt_sdk::{
13 CorrectionDirection, IndependenceRoute, ResidualClass, ResidualEvent, ResidualSeverity,
14 SensorRef,
15};
16
17use crate::runtime::{default_classify_runtime, SmokeInvocation};
18use crate::LanguageId;
19
20pub trait LanguageAdapter: Send + Sync {
22 fn id(&self) -> LanguageId;
24 fn diagnostic_sensor(&self) -> SensorRef;
26 fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent>;
28 fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection>;
30
31 fn smoke_invocations(&self, _workspace: &Path) -> Vec<SmokeInvocation> {
35 Vec::new()
36 }
37
38 fn classify_runtime(
41 &self,
42 node_id: &str,
43 generation: u32,
44 invocation: &SmokeInvocation,
45 exit_success: bool,
46 output: &str,
47 ) -> Vec<ResidualEvent> {
48 default_classify_runtime(node_id, generation, invocation, exit_success, output)
49 }
50}
51
52fn cargo_package_name(manifest: &Path) -> Option<String> {
54 let content = std::fs::read_to_string(manifest).ok()?;
55 let mut in_package = false;
56 for raw in content.lines() {
57 let line = raw.trim();
58 if line.starts_with('[') {
59 in_package = line == "[package]";
60 continue;
61 }
62 if in_package {
63 if let Some(rest) = line.strip_prefix("name") {
64 if let Some(eq) = rest.trim().strip_prefix('=') {
65 return Some(eq.trim().trim_matches('"').trim_matches('\'').to_string());
66 }
67 }
68 }
69 }
70 None
71}
72
73fn residual(
74 node_id: &str,
75 generation: u32,
76 class: ResidualClass,
77 sensor: SensorRef,
78 summary: &str,
79) -> ResidualEvent {
80 let mut r = ResidualEvent::new(
81 node_id,
82 generation,
83 class,
84 ResidualSeverity::Error,
85 1.0,
86 sensor,
87 )
88 .expect("unit score is valid");
89 r.evidence.summary = summary.to_string();
90 r
91}
92
93fn fingerprint(parser_id: &str) -> String {
96 format!("{parser_id}+{}", crate::diag::CLUSTER_PROFILE_V1)
97}
98
99fn structured_residuals(
104 node_id: &str,
105 generation: u32,
106 diagnostics: Vec<crate::diag::StructuredDiagnostic>,
107 raw: &str,
108 sensor: SensorRef,
109) -> Vec<ResidualEvent> {
110 crate::diag::cluster(diagnostics)
111 .into_iter()
112 .filter_map(|cluster| {
113 let summary = if cluster.members.len() > 1 {
114 format!(
115 "{} ({} diagnostics in cluster {})",
116 cluster.root.message,
117 cluster.members.len(),
118 cluster.code
119 )
120 } else {
121 format!("{} ({})", cluster.root.message, cluster.code)
122 };
123 crate::diag::cluster_residual(
124 node_id,
125 generation,
126 cluster.class,
127 cluster.magnitude(),
128 sensor.clone(),
129 &summary,
130 raw,
131 &cluster.members,
132 )
133 .ok()
134 })
135 .collect()
136}
137
138#[derive(Debug, Clone, Default)]
142pub struct RustAdapter;
143
144pub fn classify_rust_code(code: &str) -> ResidualClass {
146 match code {
147 "E0432" | "E0433" | "E0583" | "E0761" => ResidualClass::ImportGraph,
149 "E0412" | "E0425" | "E0422" | "E0531" => ResidualClass::SymbolMismatch,
151 "E0308" | "E0277" | "E0599" | "E0061" => ResidualClass::Type,
153 "E0382" | "E0499" | "E0502" | "E0505" | "E0506" | "E0597" => {
155 ResidualClass::OwnershipViolation
156 }
157 "E0603" | "E0616" => ResidualClass::InterfaceMismatch,
159 _ => ResidualClass::Type,
161 }
162}
163
164impl LanguageAdapter for RustAdapter {
165 fn id(&self) -> LanguageId {
166 LanguageId::new("rust")
167 }
168
169 fn diagnostic_sensor(&self) -> SensorRef {
170 SensorRef::new("rustc", IndependenceRoute::Compiler)
171 }
172
173 fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent> {
174 if crate::diag::cargo::looks_like_stream(raw) {
177 let sensor = self
178 .diagnostic_sensor()
179 .with_fingerprint(fingerprint(crate::diag::cargo::PARSER_ID));
180 let mut residuals = structured_residuals(
181 node_id,
182 generation,
183 crate::diag::cargo::parse(raw),
184 raw,
185 sensor,
186 );
187 residuals.extend(rust_test_failures(node_id, generation, raw));
188 return residuals;
189 }
190 let sensor = self
193 .diagnostic_sensor()
194 .with_fingerprint("rustc-text-v1".to_string());
195 let mut residuals = Vec::new();
196 for line in raw.lines() {
197 let line = line.trim();
198 if let Some(rest) = line.strip_prefix("error[") {
200 if let Some(end) = rest.find(']') {
201 let code = &rest[..end];
202 let class = classify_rust_code(code);
203 let summary = rest[end + 1..].trim_start_matches(':').trim();
204 residuals.push(residual(
205 node_id,
206 generation,
207 class,
208 sensor.clone(),
209 summary,
210 ));
211 }
212 }
213 }
214 residuals.extend(rust_test_failures(node_id, generation, raw));
215 residuals
216 }
217
218 fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection> {
219 let summary = &residual.evidence.summary;
220 match residual.class {
221 ResidualClass::ImportGraph => Some(
222 CorrectionDirection::new(
223 ResidualClass::ImportGraph,
224 format!(
225 "resolve the unresolved import ({summary}): add the missing `use` path or \
226 declare the missing `mod`; do not regenerate unrelated code"
227 ),
228 )
229 .with_rationale("unresolved imports are structural, not behavioral"),
230 ),
231 ResidualClass::SymbolMismatch => Some(CorrectionDirection::new(
232 ResidualClass::SymbolMismatch,
233 format!("define or correct the referenced name ({summary}); check spelling and path"),
234 )),
235 ResidualClass::Type => Some(CorrectionDirection::new(
236 ResidualClass::Type,
237 format!("reconcile the type/trait mismatch ({summary}); keep the public signature stable"),
238 )),
239 ResidualClass::OwnershipViolation => Some(CorrectionDirection::new(
240 ResidualClass::OwnershipViolation,
241 format!("fix the borrow/ownership error ({summary}); clone, borrow, or restructure \
242 lifetimes"),
243 )),
244 ResidualClass::InterfaceMismatch => Some(CorrectionDirection::new(
245 ResidualClass::InterfaceMismatch,
246 format!("adjust visibility ({summary}); make the item `pub` or use an accessible path"),
247 )),
248 ResidualClass::TestFailure => Some(CorrectionDirection::new(
249 ResidualClass::TestFailure,
250 "fix the implementation the failing test attributes to; do not weaken the assertion",
251 )),
252 ResidualClass::Runtime => Some(CorrectionDirection::new(
253 ResidualClass::Runtime,
254 format!(
255 "the built binary failed when actually run ({summary}); fix the runtime logic \
256 (panics, index/shape mismatches, unwraps) so every entrypoint executes \
257 cleanly, and add a test/example covering that runtime path"
258 ),
259 )),
260 _ => None,
261 }
262 }
263
264 fn smoke_invocations(&self, workspace: &Path) -> Vec<SmokeInvocation> {
265 let mut out = Vec::new();
266 for (name, _dir) in rust_binary_crates(workspace) {
269 out.push(SmokeInvocation::new(
270 format!("cargo run -q -p {name} -- --help"),
271 format!("{name} --help"),
272 ));
273 }
274 for (pkg, example) in rust_examples(workspace) {
278 let cmd = match pkg {
279 Some(ref p) => format!("cargo run -q -p {p} --example {example}"),
280 None => format!("cargo run -q --example {example}"),
281 };
282 out.push(SmokeInvocation::new(cmd, format!("example {example}")));
283 }
284 out
285 }
286}
287
288fn rust_test_failures(node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent> {
290 raw.lines()
291 .map(str::trim)
292 .filter(|line| line.starts_with("test result: FAILED") || line.contains("... FAILED"))
293 .map(|line| {
294 residual(
295 node_id,
296 generation,
297 ResidualClass::TestFailure,
298 SensorRef::new("cargo-test", IndependenceRoute::TestOracle)
299 .with_fingerprint("cargo-test-text-v1".to_string()),
300 line,
301 )
302 })
303 .collect()
304}
305
306fn rust_binary_crates(workspace: &Path) -> Vec<(String, std::path::PathBuf)> {
309 let mut out = Vec::new();
310 let mut consider = |dir: std::path::PathBuf| {
311 if dir.join("src/main.rs").exists() {
312 if let Some(name) = cargo_package_name(&dir.join("Cargo.toml")) {
313 out.push((name, dir));
314 }
315 }
316 };
317 consider(workspace.to_path_buf());
318 if let Ok(entries) = std::fs::read_dir(workspace.join("crates")) {
319 for entry in entries.flatten() {
320 if entry.path().is_dir() {
321 consider(entry.path());
322 }
323 }
324 }
325 out
326}
327
328fn rust_examples(workspace: &Path) -> Vec<(Option<String>, String)> {
331 let mut out = Vec::new();
332 let collect = |dir: &Path, pkg: Option<String>, out: &mut Vec<(Option<String>, String)>| {
333 if let Ok(entries) = std::fs::read_dir(dir.join("examples")) {
334 for entry in entries.flatten() {
335 let path = entry.path();
336 if path.extension().and_then(|e| e.to_str()) == Some("rs") {
337 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
338 out.push((pkg.clone(), stem.to_string()));
339 }
340 }
341 }
342 }
343 };
344 collect(workspace, None, &mut out);
345 if let Ok(entries) = std::fs::read_dir(workspace.join("crates")) {
346 for entry in entries.flatten() {
347 if entry.path().is_dir() {
348 let pkg = cargo_package_name(&entry.path().join("Cargo.toml"));
349 collect(&entry.path(), pkg, &mut out);
350 }
351 }
352 }
353 out
354}
355
356#[derive(Debug, Clone, Default)]
363pub struct PythonAdapter;
364
365impl LanguageAdapter for PythonAdapter {
366 fn id(&self) -> LanguageId {
367 LanguageId::new("python")
368 }
369
370 fn diagnostic_sensor(&self) -> SensorRef {
371 SensorRef::new("ty", IndependenceRoute::Lsp)
372 }
373
374 fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent> {
375 let ty_diagnostics = crate::diag::ty::parse_check_text(raw);
377 if !ty_diagnostics.is_empty() {
378 let sensor = SensorRef::new("ty", IndependenceRoute::Lsp)
379 .with_fingerprint(fingerprint(crate::diag::ty::PARSER_ID));
380 let mut residuals =
381 structured_residuals(node_id, generation, ty_diagnostics, raw, sensor);
382 residuals.extend(python_test_failures(node_id, generation, raw));
383 return residuals;
384 }
385 let pyright_diagnostics = crate::diag::pyright::parse(raw);
388 if !pyright_diagnostics.is_empty() {
389 let sensor = SensorRef::new("pyright", IndependenceRoute::Lsp)
390 .with_fingerprint(fingerprint(crate::diag::pyright::PARSER_ID));
391 let mut residuals =
392 structured_residuals(node_id, generation, pyright_diagnostics, raw, sensor);
393 residuals.extend(python_test_failures(node_id, generation, raw));
394 return residuals;
395 }
396 let junit = crate::diag::pytest::parse(raw);
398 if !junit.is_empty() {
399 let sensor = SensorRef::new("pytest", IndependenceRoute::TestOracle)
400 .with_fingerprint(fingerprint(crate::diag::pytest::PARSER_ID));
401 return structured_residuals(node_id, generation, junit, raw, sensor);
402 }
403 let sensor = SensorRef::new("python-check", IndependenceRoute::Compiler)
406 .with_fingerprint("python-text-v1".to_string());
407 let mut residuals = Vec::new();
408 for line in raw.lines() {
409 let lower = line.to_lowercase();
410 let class = if lower.contains("could not be resolved")
411 || lower.contains("no module named")
412 {
413 Some(ResidualClass::ImportGraph)
414 } else if lower.contains("is not defined") || lower.contains("is possibly unbound") {
415 Some(ResidualClass::SymbolMismatch)
416 } else if lower.contains("incompatible")
417 || lower.contains("expected type")
418 || lower.contains("has type")
419 {
420 Some(ResidualClass::Type)
421 } else if lower.contains("failed") && lower.contains("test") {
422 Some(ResidualClass::TestFailure)
423 } else {
424 None
425 };
426 if let Some(class) = class {
427 let sensor = if class == ResidualClass::TestFailure {
428 SensorRef::new("pytest", IndependenceRoute::TestOracle)
429 .with_fingerprint("pytest-text-v1".to_string())
430 } else {
431 sensor.clone()
432 };
433 residuals.push(residual(node_id, generation, class, sensor, line.trim()));
434 }
435 }
436 residuals
437 }
438
439 fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection> {
440 let summary = &residual.evidence.summary;
441 match residual.class {
442 ResidualClass::ImportGraph => Some(CorrectionDirection::new(
443 ResidualClass::ImportGraph,
444 format!(
445 "add the missing import or install/declare the package ({summary}); sync the \
446 environment"
447 ),
448 )),
449 ResidualClass::SymbolMismatch => Some(CorrectionDirection::new(
450 ResidualClass::SymbolMismatch,
451 format!("define the referenced name or fix its binding ({summary})"),
452 )),
453 ResidualClass::Type => Some(CorrectionDirection::new(
454 ResidualClass::Type,
455 format!(
456 "reconcile the type mismatch ({summary}); adjust the value or the annotation"
457 ),
458 )),
459 ResidualClass::TestFailure => Some(CorrectionDirection::new(
460 ResidualClass::TestFailure,
461 "fix the code under the failing pytest case; preserve the assertion",
462 )),
463 ResidualClass::Runtime => Some(CorrectionDirection::new(
464 ResidualClass::Runtime,
465 format!(
466 "the package failed when actually run/imported ({summary}); fix the runtime \
467 error (import-time exceptions, shape/type mismatches) and add a test/example \
468 covering that path"
469 ),
470 )),
471 _ => None,
472 }
473 }
474
475 fn smoke_invocations(&self, workspace: &Path) -> Vec<SmokeInvocation> {
476 python_packages(workspace)
479 .into_iter()
480 .map(|pkg| {
481 SmokeInvocation::new(
482 format!("uv run python -c \"import {pkg}\""),
483 format!("import {pkg}"),
484 )
485 })
486 .collect()
487 }
488}
489
490fn python_test_failures(node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent> {
492 raw.lines()
493 .map(str::trim)
494 .filter(|line| {
495 let lower = line.to_lowercase();
496 lower.contains("failed") && lower.contains("test")
497 })
498 .map(|line| {
499 residual(
500 node_id,
501 generation,
502 ResidualClass::TestFailure,
503 SensorRef::new("pytest", IndependenceRoute::TestOracle)
504 .with_fingerprint("pytest-text-v1".to_string()),
505 line,
506 )
507 })
508 .collect()
509}
510
511fn python_packages(workspace: &Path) -> Vec<String> {
514 let mut out = Vec::new();
515 for base in [workspace.join("src"), workspace.to_path_buf()] {
516 if let Ok(entries) = std::fs::read_dir(&base) {
517 for entry in entries.flatten() {
518 let path = entry.path();
519 if path.is_dir() && path.join("__init__.py").exists() {
520 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
521 if !out.iter().any(|p| p == name) {
522 out.push(name.to_string());
523 }
524 }
525 }
526 }
527 }
528 }
529 out
530}
531
532#[derive(Debug, Clone, Default)]
536pub struct TypeScriptAdapter;
537
538pub fn classify_ts_code(code: &str) -> ResidualClass {
540 match code {
541 "TS2307" => ResidualClass::ImportGraph, "TS2304" => ResidualClass::SymbolMismatch, "TS2305" | "TS2614" => ResidualClass::InterfaceMismatch, "TS2322" | "TS2345" | "TS2769" => ResidualClass::Type, "TS6133" | "TS6192" => ResidualClass::Lint, _ => ResidualClass::Type,
547 }
548}
549
550impl LanguageAdapter for TypeScriptAdapter {
551 fn id(&self) -> LanguageId {
552 LanguageId::new("typescript")
553 }
554
555 fn diagnostic_sensor(&self) -> SensorRef {
556 SensorRef::new("tsc", IndependenceRoute::Compiler)
557 }
558
559 fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent> {
560 let structured = crate::diag::tsc::parse(raw);
563 if !structured.is_empty() {
564 let sensor = self
565 .diagnostic_sensor()
566 .with_fingerprint(fingerprint(crate::diag::tsc::PARSER_ID));
567 return structured_residuals(node_id, generation, structured, raw, sensor);
568 }
569 let sensor = self
571 .diagnostic_sensor()
572 .with_fingerprint("tsc-loose-text-v1".to_string());
573 let mut residuals = Vec::new();
574 for line in raw.lines() {
575 if let Some(idx) = line.find("error TS") {
576 let rest = &line[idx + "error ".len()..];
577 let code: String = rest
578 .chars()
579 .take_while(|c| !c.is_whitespace() && *c != ':')
580 .collect();
581 let class = classify_ts_code(&code);
582 let summary = rest.split_once(':').map(|(_, s)| s.trim()).unwrap_or(rest);
583 residuals.push(residual(
584 node_id,
585 generation,
586 class,
587 sensor.clone(),
588 summary,
589 ));
590 }
591 }
592 residuals
593 }
594
595 fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection> {
596 let summary = &residual.evidence.summary;
597 match residual.class {
598 ResidualClass::ImportGraph => Some(CorrectionDirection::new(
599 ResidualClass::ImportGraph,
600 format!(
601 "fix the module path or add the dependency ({summary}); check tsconfig path \
602 aliases"
603 ),
604 )),
605 ResidualClass::SymbolMismatch => Some(CorrectionDirection::new(
606 ResidualClass::SymbolMismatch,
607 format!("import or declare the missing name ({summary})"),
608 )),
609 ResidualClass::InterfaceMismatch => Some(CorrectionDirection::new(
610 ResidualClass::InterfaceMismatch,
611 format!("export the missing member or fix the import binding ({summary})"),
612 )),
613 ResidualClass::Type => Some(CorrectionDirection::new(
614 ResidualClass::Type,
615 format!("reconcile the type mismatch ({summary})"),
616 )),
617 ResidualClass::Lint => Some(CorrectionDirection::new(
618 ResidualClass::Lint,
619 format!("remove the unused symbol ({summary})"),
620 )),
621 _ => None,
622 }
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 #[test]
631 fn rust_unresolved_import_classified_and_directed() {
632 let adapter = RustAdapter;
633 let raw = "error[E0432]: unresolved import `crate::foo::Bar`";
634 let residuals = adapter.parse_diagnostics("n1", 0, raw);
635 assert_eq!(residuals.len(), 1);
636 assert_eq!(residuals[0].class, ResidualClass::ImportGraph);
637 let dir = adapter.correction_for(&residuals[0]).unwrap();
638 assert_eq!(dir.addresses, ResidualClass::ImportGraph);
639 assert!(dir.instruction.contains("use"));
640 }
641
642 #[test]
643 fn rust_classifies_a_spread_of_codes() {
644 assert_eq!(classify_rust_code("E0308"), ResidualClass::Type);
645 assert_eq!(
646 classify_rust_code("E0382"),
647 ResidualClass::OwnershipViolation
648 );
649 assert_eq!(
650 classify_rust_code("E0603"),
651 ResidualClass::InterfaceMismatch
652 );
653 assert_eq!(classify_rust_code("E0425"), ResidualClass::SymbolMismatch);
654 }
655
656 #[test]
657 fn rust_test_failure_parsed() {
658 let adapter = RustAdapter;
659 let raw = "test tests::it_works ... FAILED";
660 let residuals = adapter.parse_diagnostics("n1", 0, raw);
661 assert_eq!(residuals[0].class, ResidualClass::TestFailure);
662 assert_eq!(residuals[0].sensor.route, IndependenceRoute::TestOracle);
663 }
664
665 #[test]
666 fn python_import_and_type_classified() {
667 let adapter = PythonAdapter;
668 let raw = "x.py:1: error: Import \"requests\" could not be resolved\nx.py:2: error: Argument 1 has \
669 incompatible type \"str\"";
670 let residuals = adapter.parse_diagnostics("n1", 0, raw);
671 assert_eq!(residuals.len(), 2);
672 assert_eq!(residuals[0].class, ResidualClass::ImportGraph);
673 assert_eq!(residuals[1].class, ResidualClass::Type);
674 }
675
676 #[test]
677 fn typescript_codes_classified_and_directed() {
678 let adapter = TypeScriptAdapter;
679 let raw = "src/a.ts(3,10): error TS2307: Cannot find module 'foo'.\nsrc/b.ts(4,2): error TS2322: \
680 Type 'string' is not assignable to type 'number'.";
681 let residuals = adapter.parse_diagnostics("n1", 0, raw);
682 assert_eq!(residuals.len(), 2);
683 assert_eq!(residuals[0].class, ResidualClass::ImportGraph);
684 assert_eq!(residuals[1].class, ResidualClass::Type);
685 assert!(adapter.correction_for(&residuals[0]).is_some());
686 }
687
688 #[test]
689 fn rust_smoke_discovers_workspace_binaries_and_examples() {
690 let dir = std::env::temp_dir().join(format!(
691 "perspt-smoke-rust-{}",
692 std::time::SystemTime::now()
693 .duration_since(std::time::UNIX_EPOCH)
694 .unwrap()
695 .as_nanos()
696 ));
697 std::fs::create_dir_all(dir.join("crates/cli/src")).unwrap();
698 std::fs::create_dir_all(dir.join("crates/cli/examples")).unwrap();
699 std::fs::write(
700 dir.join("crates/cli/Cargo.toml"),
701 "[package]\nname = \"weather-cli\"\n",
702 )
703 .unwrap();
704 std::fs::write(dir.join("crates/cli/src/main.rs"), "fn main() {}\n").unwrap();
705 std::fs::write(dir.join("crates/cli/examples/demo.rs"), "fn main() {}\n").unwrap();
706
707 let inv = RustAdapter.smoke_invocations(&dir);
708 assert!(
709 inv.iter()
710 .any(|i| i.command == "cargo run -q -p weather-cli -- --help"),
711 "got {inv:?}"
712 );
713 assert!(
714 inv.iter().any(|i| i.command.contains("--example demo")),
715 "got {inv:?}"
716 );
717 std::fs::remove_dir_all(&dir).ok();
718 }
719
720 #[test]
721 fn python_smoke_discovers_src_layout_package() {
722 let dir = std::env::temp_dir().join(format!(
723 "perspt-smoke-py-{}",
724 std::time::SystemTime::now()
725 .duration_since(std::time::UNIX_EPOCH)
726 .unwrap()
727 .as_nanos()
728 ));
729 std::fs::create_dir_all(dir.join("src/rpncalc")).unwrap();
730 std::fs::write(dir.join("src/rpncalc/__init__.py"), "").unwrap();
731
732 let inv = PythonAdapter.smoke_invocations(&dir);
733 assert!(
734 inv.iter().any(|i| i.command.contains("import rpncalc")),
735 "got {inv:?}"
736 );
737 std::fs::remove_dir_all(&dir).ok();
738 }
739
740 #[test]
741 fn adapter_for_dispatches_by_language() {
742 assert_eq!(RustAdapter.id(), LanguageId::new("rust"));
743 assert_eq!(PythonAdapter.id(), LanguageId::new("python"));
744 assert_eq!(TypeScriptAdapter.id(), LanguageId::new("typescript"));
745 }
746}