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::CodingLanguage;
19
20pub trait LanguageAdapter: Send + Sync {
22 fn language(&self) -> CodingLanguage;
23 fn diagnostic_sensor(&self) -> SensorRef;
25 fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent>;
27 fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection>;
29
30 fn smoke_invocations(&self, _workspace: &Path) -> Vec<SmokeInvocation> {
34 Vec::new()
35 }
36
37 fn classify_runtime(
40 &self,
41 node_id: &str,
42 generation: u32,
43 invocation: &SmokeInvocation,
44 exit_success: bool,
45 output: &str,
46 ) -> Vec<ResidualEvent> {
47 default_classify_runtime(node_id, generation, invocation, exit_success, output)
48 }
49}
50
51fn cargo_package_name(manifest: &Path) -> Option<String> {
53 let content = std::fs::read_to_string(manifest).ok()?;
54 let mut in_package = false;
55 for raw in content.lines() {
56 let line = raw.trim();
57 if line.starts_with('[') {
58 in_package = line == "[package]";
59 continue;
60 }
61 if in_package {
62 if let Some(rest) = line.strip_prefix("name") {
63 if let Some(eq) = rest.trim().strip_prefix('=') {
64 return Some(eq.trim().trim_matches('"').trim_matches('\'').to_string());
65 }
66 }
67 }
68 }
69 None
70}
71
72pub fn adapter_for(language: CodingLanguage) -> Box<dyn LanguageAdapter> {
74 match language {
75 CodingLanguage::Rust => Box::new(RustAdapter),
76 CodingLanguage::Python => Box::new(PythonAdapter),
77 CodingLanguage::TypeScript => Box::new(TypeScriptAdapter),
78 }
79}
80
81fn residual(
82 node_id: &str,
83 generation: u32,
84 class: ResidualClass,
85 sensor: SensorRef,
86 summary: &str,
87) -> ResidualEvent {
88 let mut r = ResidualEvent::new(
89 node_id,
90 generation,
91 class,
92 ResidualSeverity::Error,
93 1.0,
94 sensor,
95 )
96 .expect("unit score is valid");
97 r.evidence.summary = summary.to_string();
98 r
99}
100
101#[derive(Debug, Clone, Default)]
105pub struct RustAdapter;
106
107pub fn classify_rust_code(code: &str) -> ResidualClass {
109 match code {
110 "E0432" | "E0433" | "E0583" | "E0761" => ResidualClass::ImportGraph,
112 "E0412" | "E0425" | "E0422" | "E0531" => ResidualClass::SymbolMismatch,
114 "E0308" | "E0277" | "E0599" | "E0061" => ResidualClass::Type,
116 "E0382" | "E0499" | "E0502" | "E0505" | "E0506" | "E0597" => {
118 ResidualClass::OwnershipViolation
119 }
120 "E0603" | "E0616" => ResidualClass::InterfaceMismatch,
122 _ => ResidualClass::Type,
124 }
125}
126
127impl LanguageAdapter for RustAdapter {
128 fn language(&self) -> CodingLanguage {
129 CodingLanguage::Rust
130 }
131
132 fn diagnostic_sensor(&self) -> SensorRef {
133 SensorRef::new("rustc", IndependenceRoute::Compiler)
134 }
135
136 fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent> {
137 let mut residuals = Vec::new();
138 for line in raw.lines() {
139 let line = line.trim();
140 if let Some(rest) = line.strip_prefix("error[") {
142 if let Some(end) = rest.find(']') {
143 let code = &rest[..end];
144 let class = classify_rust_code(code);
145 let summary = rest[end + 1..].trim_start_matches(':').trim();
146 residuals.push(residual(
147 node_id,
148 generation,
149 class,
150 self.diagnostic_sensor(),
151 summary,
152 ));
153 }
154 } else if line.starts_with("test result: FAILED") || line.contains("... FAILED") {
155 residuals.push(residual(
156 node_id,
157 generation,
158 ResidualClass::TestFailure,
159 SensorRef::new("cargo-test", IndependenceRoute::TestOracle),
160 line,
161 ));
162 }
163 }
164 residuals
165 }
166
167 fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection> {
168 let summary = &residual.evidence.summary;
169 match residual.class {
170 ResidualClass::ImportGraph => Some(
171 CorrectionDirection::new(
172 ResidualClass::ImportGraph,
173 format!(
174 "resolve the unresolved import ({summary}): add the missing `use` path or \
175 declare the missing `mod`; do not regenerate unrelated code"
176 ),
177 )
178 .with_rationale("unresolved imports are structural, not behavioral"),
179 ),
180 ResidualClass::SymbolMismatch => Some(CorrectionDirection::new(
181 ResidualClass::SymbolMismatch,
182 format!("define or correct the referenced name ({summary}); check spelling and path"),
183 )),
184 ResidualClass::Type => Some(CorrectionDirection::new(
185 ResidualClass::Type,
186 format!("reconcile the type/trait mismatch ({summary}); keep the public signature stable"),
187 )),
188 ResidualClass::OwnershipViolation => Some(CorrectionDirection::new(
189 ResidualClass::OwnershipViolation,
190 format!("fix the borrow/ownership error ({summary}); clone, borrow, or restructure lifetimes"),
191 )),
192 ResidualClass::InterfaceMismatch => Some(CorrectionDirection::new(
193 ResidualClass::InterfaceMismatch,
194 format!("adjust visibility ({summary}); make the item `pub` or use an accessible path"),
195 )),
196 ResidualClass::TestFailure => Some(CorrectionDirection::new(
197 ResidualClass::TestFailure,
198 "fix the implementation the failing test attributes to; do not weaken the assertion",
199 )),
200 ResidualClass::Runtime => Some(CorrectionDirection::new(
201 ResidualClass::Runtime,
202 format!(
203 "the built binary failed when actually run ({summary}); fix the runtime logic \
204 (panics, index/shape mismatches, unwraps) so every entrypoint executes \
205 cleanly, and add a test/example covering that runtime path"
206 ),
207 )),
208 _ => None,
209 }
210 }
211
212 fn smoke_invocations(&self, workspace: &Path) -> Vec<SmokeInvocation> {
213 let mut out = Vec::new();
214 for (name, _dir) in rust_binary_crates(workspace) {
217 out.push(SmokeInvocation::new(
218 format!("cargo run -q -p {name} -- --help"),
219 format!("{name} --help"),
220 ));
221 }
222 for (pkg, example) in rust_examples(workspace) {
226 let cmd = match pkg {
227 Some(ref p) => format!("cargo run -q -p {p} --example {example}"),
228 None => format!("cargo run -q --example {example}"),
229 };
230 out.push(SmokeInvocation::new(cmd, format!("example {example}")));
231 }
232 out
233 }
234}
235
236fn rust_binary_crates(workspace: &Path) -> Vec<(String, std::path::PathBuf)> {
239 let mut out = Vec::new();
240 let mut consider = |dir: std::path::PathBuf| {
241 if dir.join("src/main.rs").exists() {
242 if let Some(name) = cargo_package_name(&dir.join("Cargo.toml")) {
243 out.push((name, dir));
244 }
245 }
246 };
247 consider(workspace.to_path_buf());
248 if let Ok(entries) = std::fs::read_dir(workspace.join("crates")) {
249 for entry in entries.flatten() {
250 if entry.path().is_dir() {
251 consider(entry.path());
252 }
253 }
254 }
255 out
256}
257
258fn rust_examples(workspace: &Path) -> Vec<(Option<String>, String)> {
261 let mut out = Vec::new();
262 let collect = |dir: &Path, pkg: Option<String>, out: &mut Vec<(Option<String>, String)>| {
263 if let Ok(entries) = std::fs::read_dir(dir.join("examples")) {
264 for entry in entries.flatten() {
265 let path = entry.path();
266 if path.extension().and_then(|e| e.to_str()) == Some("rs") {
267 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
268 out.push((pkg.clone(), stem.to_string()));
269 }
270 }
271 }
272 }
273 };
274 collect(workspace, None, &mut out);
275 if let Ok(entries) = std::fs::read_dir(workspace.join("crates")) {
276 for entry in entries.flatten() {
277 if entry.path().is_dir() {
278 let pkg = cargo_package_name(&entry.path().join("Cargo.toml"));
279 collect(&entry.path(), pkg, &mut out);
280 }
281 }
282 }
283 out
284}
285
286#[derive(Debug, Clone, Default)]
290pub struct PythonAdapter;
291
292impl LanguageAdapter for PythonAdapter {
293 fn language(&self) -> CodingLanguage {
294 CodingLanguage::Python
295 }
296
297 fn diagnostic_sensor(&self) -> SensorRef {
298 SensorRef::new("pyright", IndependenceRoute::Lsp)
299 }
300
301 fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent> {
302 let mut residuals = Vec::new();
303 for line in raw.lines() {
304 let lower = line.to_lowercase();
305 let class = if lower.contains("could not be resolved")
306 || lower.contains("no module named")
307 {
308 Some(ResidualClass::ImportGraph)
309 } else if lower.contains("is not defined") || lower.contains("is possibly unbound") {
310 Some(ResidualClass::SymbolMismatch)
311 } else if lower.contains("incompatible")
312 || lower.contains("expected type")
313 || lower.contains("has type")
314 {
315 Some(ResidualClass::Type)
316 } else if lower.contains("failed") && lower.contains("test") {
317 Some(ResidualClass::TestFailure)
318 } else {
319 None
320 };
321 if let Some(class) = class {
322 let sensor = if class == ResidualClass::TestFailure {
323 SensorRef::new("pytest", IndependenceRoute::TestOracle)
324 } else {
325 self.diagnostic_sensor()
326 };
327 residuals.push(residual(node_id, generation, class, sensor, line.trim()));
328 }
329 }
330 residuals
331 }
332
333 fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection> {
334 let summary = &residual.evidence.summary;
335 match residual.class {
336 ResidualClass::ImportGraph => Some(CorrectionDirection::new(
337 ResidualClass::ImportGraph,
338 format!("add the missing import or install/declare the package ({summary}); sync the environment"),
339 )),
340 ResidualClass::SymbolMismatch => Some(CorrectionDirection::new(
341 ResidualClass::SymbolMismatch,
342 format!("define the referenced name or fix its binding ({summary})"),
343 )),
344 ResidualClass::Type => Some(CorrectionDirection::new(
345 ResidualClass::Type,
346 format!("reconcile the type mismatch ({summary}); adjust the value or the annotation"),
347 )),
348 ResidualClass::TestFailure => Some(CorrectionDirection::new(
349 ResidualClass::TestFailure,
350 "fix the code under the failing pytest case; preserve the assertion",
351 )),
352 ResidualClass::Runtime => Some(CorrectionDirection::new(
353 ResidualClass::Runtime,
354 format!(
355 "the package failed when actually run/imported ({summary}); fix the runtime \
356 error (import-time exceptions, shape/type mismatches) and add a test/example \
357 covering that path"
358 ),
359 )),
360 _ => None,
361 }
362 }
363
364 fn smoke_invocations(&self, workspace: &Path) -> Vec<SmokeInvocation> {
365 python_packages(workspace)
368 .into_iter()
369 .map(|pkg| {
370 SmokeInvocation::new(
371 format!("uv run python -c \"import {pkg}\""),
372 format!("import {pkg}"),
373 )
374 })
375 .collect()
376 }
377}
378
379fn python_packages(workspace: &Path) -> Vec<String> {
382 let mut out = Vec::new();
383 for base in [workspace.join("src"), workspace.to_path_buf()] {
384 if let Ok(entries) = std::fs::read_dir(&base) {
385 for entry in entries.flatten() {
386 let path = entry.path();
387 if path.is_dir() && path.join("__init__.py").exists() {
388 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
389 if !out.iter().any(|p| p == name) {
390 out.push(name.to_string());
391 }
392 }
393 }
394 }
395 }
396 }
397 out
398}
399
400#[derive(Debug, Clone, Default)]
404pub struct TypeScriptAdapter;
405
406pub fn classify_ts_code(code: &str) -> ResidualClass {
408 match code {
409 "TS2307" => ResidualClass::ImportGraph, "TS2304" => ResidualClass::SymbolMismatch, "TS2305" | "TS2614" => ResidualClass::InterfaceMismatch, "TS2322" | "TS2345" | "TS2769" => ResidualClass::Type, "TS6133" | "TS6192" => ResidualClass::Lint, _ => ResidualClass::Type,
415 }
416}
417
418impl LanguageAdapter for TypeScriptAdapter {
419 fn language(&self) -> CodingLanguage {
420 CodingLanguage::TypeScript
421 }
422
423 fn diagnostic_sensor(&self) -> SensorRef {
424 SensorRef::new("tsc", IndependenceRoute::Compiler)
425 }
426
427 fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent> {
428 let mut residuals = Vec::new();
429 for line in raw.lines() {
430 if let Some(idx) = line.find("error TS") {
432 let rest = &line[idx + "error ".len()..];
433 let code: String = rest
434 .chars()
435 .take_while(|c| !c.is_whitespace() && *c != ':')
436 .collect();
437 let class = classify_ts_code(&code);
438 let summary = rest.split_once(':').map(|(_, s)| s.trim()).unwrap_or(rest);
439 residuals.push(residual(
440 node_id,
441 generation,
442 class,
443 self.diagnostic_sensor(),
444 summary,
445 ));
446 }
447 }
448 residuals
449 }
450
451 fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection> {
452 let summary = &residual.evidence.summary;
453 match residual.class {
454 ResidualClass::ImportGraph => Some(CorrectionDirection::new(
455 ResidualClass::ImportGraph,
456 format!("fix the module path or add the dependency ({summary}); check tsconfig path aliases"),
457 )),
458 ResidualClass::SymbolMismatch => Some(CorrectionDirection::new(
459 ResidualClass::SymbolMismatch,
460 format!("import or declare the missing name ({summary})"),
461 )),
462 ResidualClass::InterfaceMismatch => Some(CorrectionDirection::new(
463 ResidualClass::InterfaceMismatch,
464 format!("export the missing member or fix the import binding ({summary})"),
465 )),
466 ResidualClass::Type => Some(CorrectionDirection::new(
467 ResidualClass::Type,
468 format!("reconcile the type mismatch ({summary})"),
469 )),
470 ResidualClass::Lint => Some(CorrectionDirection::new(
471 ResidualClass::Lint,
472 format!("remove the unused symbol ({summary})"),
473 )),
474 _ => None,
475 }
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn rust_unresolved_import_classified_and_directed() {
485 let adapter = RustAdapter;
486 let raw = "error[E0432]: unresolved import `crate::foo::Bar`";
487 let residuals = adapter.parse_diagnostics("n1", 0, raw);
488 assert_eq!(residuals.len(), 1);
489 assert_eq!(residuals[0].class, ResidualClass::ImportGraph);
490 let dir = adapter.correction_for(&residuals[0]).unwrap();
491 assert_eq!(dir.addresses, ResidualClass::ImportGraph);
492 assert!(dir.instruction.contains("use"));
493 }
494
495 #[test]
496 fn rust_classifies_a_spread_of_codes() {
497 assert_eq!(classify_rust_code("E0308"), ResidualClass::Type);
498 assert_eq!(
499 classify_rust_code("E0382"),
500 ResidualClass::OwnershipViolation
501 );
502 assert_eq!(
503 classify_rust_code("E0603"),
504 ResidualClass::InterfaceMismatch
505 );
506 assert_eq!(classify_rust_code("E0425"), ResidualClass::SymbolMismatch);
507 }
508
509 #[test]
510 fn rust_test_failure_parsed() {
511 let adapter = RustAdapter;
512 let raw = "test tests::it_works ... FAILED";
513 let residuals = adapter.parse_diagnostics("n1", 0, raw);
514 assert_eq!(residuals[0].class, ResidualClass::TestFailure);
515 assert_eq!(residuals[0].sensor.route, IndependenceRoute::TestOracle);
516 }
517
518 #[test]
519 fn python_import_and_type_classified() {
520 let adapter = PythonAdapter;
521 let raw = "x.py:1: error: Import \"requests\" could not be resolved\nx.py:2: error: Argument 1 has incompatible type \"str\"";
522 let residuals = adapter.parse_diagnostics("n1", 0, raw);
523 assert_eq!(residuals.len(), 2);
524 assert_eq!(residuals[0].class, ResidualClass::ImportGraph);
525 assert_eq!(residuals[1].class, ResidualClass::Type);
526 }
527
528 #[test]
529 fn typescript_codes_classified_and_directed() {
530 let adapter = TypeScriptAdapter;
531 let raw = "src/a.ts(3,10): error TS2307: Cannot find module 'foo'.\nsrc/b.ts(4,2): error TS2322: Type 'string' is not assignable to type 'number'.";
532 let residuals = adapter.parse_diagnostics("n1", 0, raw);
533 assert_eq!(residuals.len(), 2);
534 assert_eq!(residuals[0].class, ResidualClass::ImportGraph);
535 assert_eq!(residuals[1].class, ResidualClass::Type);
536 assert!(adapter.correction_for(&residuals[0]).is_some());
537 }
538
539 #[test]
540 fn rust_smoke_discovers_workspace_binaries_and_examples() {
541 let dir = std::env::temp_dir().join(format!(
542 "perspt-smoke-rust-{}",
543 std::time::SystemTime::now()
544 .duration_since(std::time::UNIX_EPOCH)
545 .unwrap()
546 .as_nanos()
547 ));
548 std::fs::create_dir_all(dir.join("crates/cli/src")).unwrap();
549 std::fs::create_dir_all(dir.join("crates/cli/examples")).unwrap();
550 std::fs::write(
551 dir.join("crates/cli/Cargo.toml"),
552 "[package]\nname = \"weather-cli\"\n",
553 )
554 .unwrap();
555 std::fs::write(dir.join("crates/cli/src/main.rs"), "fn main() {}\n").unwrap();
556 std::fs::write(dir.join("crates/cli/examples/demo.rs"), "fn main() {}\n").unwrap();
557
558 let inv = RustAdapter.smoke_invocations(&dir);
559 assert!(
560 inv.iter()
561 .any(|i| i.command == "cargo run -q -p weather-cli -- --help"),
562 "got {inv:?}"
563 );
564 assert!(
565 inv.iter().any(|i| i.command.contains("--example demo")),
566 "got {inv:?}"
567 );
568 std::fs::remove_dir_all(&dir).ok();
569 }
570
571 #[test]
572 fn python_smoke_discovers_src_layout_package() {
573 let dir = std::env::temp_dir().join(format!(
574 "perspt-smoke-py-{}",
575 std::time::SystemTime::now()
576 .duration_since(std::time::UNIX_EPOCH)
577 .unwrap()
578 .as_nanos()
579 ));
580 std::fs::create_dir_all(dir.join("src/rpncalc")).unwrap();
581 std::fs::write(dir.join("src/rpncalc/__init__.py"), "").unwrap();
582
583 let inv = PythonAdapter.smoke_invocations(&dir);
584 assert!(
585 inv.iter().any(|i| i.command.contains("import rpncalc")),
586 "got {inv:?}"
587 );
588 std::fs::remove_dir_all(&dir).ok();
589 }
590
591 #[test]
592 fn adapter_for_dispatches_by_language() {
593 assert_eq!(
594 adapter_for(CodingLanguage::Rust).language(),
595 CodingLanguage::Rust
596 );
597 assert_eq!(
598 adapter_for(CodingLanguage::Python).language(),
599 CodingLanguage::Python
600 );
601 assert_eq!(
602 adapter_for(CodingLanguage::TypeScript).language(),
603 CodingLanguage::TypeScript
604 );
605 }
606}