Skip to main content

perspt_coding/
lang.rs

1//! Language adapters (PSP-8 System 5 / Gate D).
2//!
3//! A coding language adapter evolves from a command selector into a verifier
4//! suite: it parses compiler/type-checker/test output into typed
5//! [`ResidualEvent`]s and maps each into a [`CorrectionDirection`]. Actually
6//! *running* the tools is the runtime's job; the testable core here is the
7//! normalization from raw diagnostic text to residual evidence, which is what
8//! makes corrections directed rather than undirected retries.
9
10use 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
20/// A coding language adapter: a verifier-suite provider for one language.
21pub trait LanguageAdapter: Send + Sync {
22    /// The open, stable language identifier this adapter answers to.
23    fn id(&self) -> LanguageId;
24    /// The primary diagnostic sensor for this language.
25    fn diagnostic_sensor(&self) -> SensorRef;
26    /// Parse raw diagnostic output into typed residuals.
27    fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent>;
28    /// Map a residual to a correction direction, or `None` when there is none.
29    fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection>;
30
31    /// Runtime smoke invocations to exercise the built artifact's entrypoints
32    /// (PSP-8 runtime probe). Default: none — a new adapter opts in by overriding
33    /// this. The runtime executes the returned commands from `workspace`.
34    fn smoke_invocations(&self, _workspace: &Path) -> Vec<SmokeInvocation> {
35        Vec::new()
36    }
37
38    /// Classify the output of a smoke invocation into `Runtime` residuals.
39    /// Default: shared crash-marker + non-zero-exit detection.
40    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
52/// Read the `[package] name` from a Cargo.toml, for naming `cargo run -p` targets.
53fn 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
93/// The sensor fingerprint: tool identity, parser version, and the cluster
94/// normalization profile (PSP-10 Assumption 2).
95fn fingerprint(parser_id: &str) -> String {
96    format!("{parser_id}+{}", crate::diag::CLUSTER_PROFILE_V1)
97}
98
99/// Fold structured diagnostics into cluster-level residuals with full
100/// evidence (PSP-10 system 26). One residual per root-cause cluster; the
101/// magnitude is the profile-normalized cluster magnitude, never a raw
102/// diagnostic count.
103fn 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// ============================ Rust ============================
139
140/// The Rust verifier-suite adapter (rustc / cargo / rust-analyzer).
141#[derive(Debug, Clone, Default)]
142pub struct RustAdapter;
143
144/// Classify a rustc error code into a residual class.
145pub fn classify_rust_code(code: &str) -> ResidualClass {
146    match code {
147        // Unresolved imports / missing modules.
148        "E0432" | "E0433" | "E0583" | "E0761" => ResidualClass::ImportGraph,
149        // Cannot find name / value / type.
150        "E0412" | "E0425" | "E0422" | "E0531" => ResidualClass::SymbolMismatch,
151        // Type / trait-bound mismatches.
152        "E0308" | "E0277" | "E0599" | "E0061" => ResidualClass::Type,
153        // Borrow / ownership / lifetimes.
154        "E0382" | "E0499" | "E0502" | "E0505" | "E0506" | "E0597" => {
155            ResidualClass::OwnershipViolation
156        }
157        // Visibility / privacy.
158        "E0603" | "E0616" => ResidualClass::InterfaceMismatch,
159        // Anything else compiler-emitted is a generic type/build residual.
160        _ => 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        // The structured plane first: cargo's native JSON stream preserves
175        // codes, spans, and suggestions (PSP-10 system 26).
176        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        // Text fallback under its own parser identity; never pooled with
191        // the JSON parser's measurements.
192        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            // `error[E0432]: unresolved import `foo``
199            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        // Binary crates: `cargo run -p <name> -- --help` exercises startup +
267        // arg parsing without needing real arguments.
268        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        // Examples are the project's own end-to-end smoke; run each one. A good
275        // example exercises the real pipeline (e.g. train→predict), so a runtime
276        // bug there is caught here.
277        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
288/// Test-failure lines from `cargo test` text output (both stream shapes).
289fn 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
306/// Discover binary crates (those with `src/main.rs`) in a Cargo workspace:
307/// the root package and any `crates/*` members. Returns `(package_name, dir)`.
308fn 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
328/// Discover example targets: `examples/*.rs` at the root (no `-p`) and under
329/// each `crates/*` member (with that member's `-p`). Returns `(package, stem)`.
330fn 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// ============================ Python ============================
357
358/// The Python verifier-suite adapter. `ty` is the primary sensor; Pyright
359/// is a separately fingerprinted fallback (PSP-10 resolved decision 15).
360/// Attribution follows the observed output format — the historic
361/// split-brain that stamped every observation "pyright" is gone.
362#[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        // `ty check --output-format concise` (primary text form).
376        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        // Pyright JSON fallback, under its own fingerprint — never pooled
386        // with `ty` measurements.
387        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        // Pytest JUnit XML.
397        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        // Legacy text scrape (stdlib checks, bare tracebacks) under the
404        // honest sensor identity of the interpreter, not a type checker.
405        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        // Import smoke: importing the package executes all module top-level code,
477        // catching import-time errors that unit tests on submodules can miss.
478        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
490/// Pytest text-output failures (when no JUnit XML was produced).
491fn 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
511/// Discover importable top-level packages: directories containing `__init__.py`
512/// under `src/` (src-layout) or the workspace root (flat layout).
513fn 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// ============================ TypeScript ============================
533
534/// The JavaScript/TypeScript verifier-suite adapter (tsc / eslint).
535#[derive(Debug, Clone, Default)]
536pub struct TypeScriptAdapter;
537
538/// Classify a TypeScript diagnostic code (e.g. `TS2307`).
539pub fn classify_ts_code(code: &str) -> ResidualClass {
540    match code {
541        "TS2307" => ResidualClass::ImportGraph, // cannot find module
542        "TS2304" => ResidualClass::SymbolMismatch, // cannot find name
543        "TS2305" | "TS2614" => ResidualClass::InterfaceMismatch, // no exported member
544        "TS2322" | "TS2345" | "TS2769" => ResidualClass::Type, // type mismatches
545        "TS6133" | "TS6192" => ResidualClass::Lint, // unused
546        _ => 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        // The versioned `tsc --pretty false` normalizer preserves paths and
561        // positions (PSP-10 system 26).
562        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        // Fallback for lines without the position prefix.
570        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}