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::CodingLanguage;
19
20/// A coding language adapter: a verifier-suite provider for one language.
21pub trait LanguageAdapter: Send + Sync {
22    fn language(&self) -> CodingLanguage;
23    /// The primary diagnostic sensor for this language.
24    fn diagnostic_sensor(&self) -> SensorRef;
25    /// Parse raw diagnostic output into typed residuals.
26    fn parse_diagnostics(&self, node_id: &str, generation: u32, raw: &str) -> Vec<ResidualEvent>;
27    /// Map a residual to a correction direction, or `None` when there is none.
28    fn correction_for(&self, residual: &ResidualEvent) -> Option<CorrectionDirection>;
29
30    /// Runtime smoke invocations to exercise the built artifact's entrypoints
31    /// (PSP-8 runtime probe). Default: none — a new adapter opts in by overriding
32    /// this. The runtime executes the returned commands from `workspace`.
33    fn smoke_invocations(&self, _workspace: &Path) -> Vec<SmokeInvocation> {
34        Vec::new()
35    }
36
37    /// Classify the output of a smoke invocation into `Runtime` residuals.
38    /// Default: shared crash-marker + non-zero-exit detection.
39    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
51/// Read the `[package] name` from a Cargo.toml, for naming `cargo run -p` targets.
52fn 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
72/// Return the adapter for a language.
73pub 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// ============================ Rust ============================
102
103/// The Rust verifier-suite adapter (rustc / cargo / rust-analyzer).
104#[derive(Debug, Clone, Default)]
105pub struct RustAdapter;
106
107/// Classify a rustc error code into a residual class.
108pub fn classify_rust_code(code: &str) -> ResidualClass {
109    match code {
110        // Unresolved imports / missing modules.
111        "E0432" | "E0433" | "E0583" | "E0761" => ResidualClass::ImportGraph,
112        // Cannot find name / value / type.
113        "E0412" | "E0425" | "E0422" | "E0531" => ResidualClass::SymbolMismatch,
114        // Type / trait-bound mismatches.
115        "E0308" | "E0277" | "E0599" | "E0061" => ResidualClass::Type,
116        // Borrow / ownership / lifetimes.
117        "E0382" | "E0499" | "E0502" | "E0505" | "E0506" | "E0597" => {
118            ResidualClass::OwnershipViolation
119        }
120        // Visibility / privacy.
121        "E0603" | "E0616" => ResidualClass::InterfaceMismatch,
122        // Anything else compiler-emitted is a generic type/build residual.
123        _ => 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            // `error[E0432]: unresolved import `foo``
141            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        // Binary crates: `cargo run -p <name> -- --help` exercises startup +
215        // arg parsing without needing real arguments.
216        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        // Examples are the project's own end-to-end smoke; run each one. A good
223        // example exercises the real pipeline (e.g. train→predict), so a runtime
224        // bug there is caught here.
225        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
236/// Discover binary crates (those with `src/main.rs`) in a Cargo workspace:
237/// the root package and any `crates/*` members. Returns `(package_name, dir)`.
238fn 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
258/// Discover example targets: `examples/*.rs` at the root (no `-p`) and under
259/// each `crates/*` member (with that member's `-p`). Returns `(package, stem)`.
260fn 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// ============================ Python ============================
287
288/// The Python verifier-suite adapter (pyright / mypy / pytest).
289#[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        // Import smoke: importing the package executes all module top-level code,
366        // catching import-time errors that unit tests on submodules can miss.
367        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
379/// Discover importable top-level packages: directories containing `__init__.py`
380/// under `src/` (src-layout) or the workspace root (flat layout).
381fn 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// ============================ TypeScript ============================
401
402/// The JavaScript/TypeScript verifier-suite adapter (tsc / eslint).
403#[derive(Debug, Clone, Default)]
404pub struct TypeScriptAdapter;
405
406/// Classify a TypeScript diagnostic code (e.g. `TS2307`).
407pub fn classify_ts_code(code: &str) -> ResidualClass {
408    match code {
409        "TS2307" => ResidualClass::ImportGraph, // cannot find module
410        "TS2304" => ResidualClass::SymbolMismatch, // cannot find name
411        "TS2305" | "TS2614" => ResidualClass::InterfaceMismatch, // no exported member
412        "TS2322" | "TS2345" | "TS2769" => ResidualClass::Type, // type mismatches
413        "TS6133" | "TS6192" => ResidualClass::Lint, // unused
414        _ => 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            // `src/x.ts(3,10): error TS2307: Cannot find module 'foo'.`
431            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}