Skip to main content

perspt_agent/orchestrator/
verification.rs

1//! Stability verification, plugin-driven checks, and dependency auto-installation.
2
3use super::*;
4
5impl SRBNOrchestrator {
6    /// Step 4: Stability Verification
7    ///
8    /// Computes Lyapunov Energy V(x) from LSP diagnostics, contracts, and tests
9    pub(super) async fn step_verify(&mut self, idx: NodeIndex) -> Result<EnergyComponents> {
10        log::info!("Step 4: Verification - Computing stability energy");
11
12        // Clear stale verification result from previous nodes to prevent
13        // cross-node data leakage into sheaf validators.
14        self.last_verification_result = None;
15        // Clear stale test output so the correction prompt doesn't show
16        // results from a previous node's verification run.
17        self.context.last_test_output = None;
18
19        self.graph[idx].state = NodeState::Verifying;
20        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
21            node_id: self.graph[idx].node_id.clone(),
22            status: perspt_core::NodeStatus::Verifying,
23        });
24
25        // Calculate energy components
26        let mut energy = EnergyComponents::default();
27
28        // V_syn: From Tool Failures (Critical)
29        if let Some(ref err) = self.last_tool_failure {
30            energy.v_syn = 10.0; // High energy for tool failure
31            log::warn!("Tool failure detected, V_syn set to 10.0: {}", err);
32            self.emit_log(format!("⚠️ Tool failure prevents verification: {}", err));
33            // We can return early or allow other checks. Usually tool failure means broken state.
34
35            // Store diagnostics mock for correction prompt
36            self.context.last_diagnostics = vec![lsp_types::Diagnostic {
37                range: lsp_types::Range::default(),
38                severity: Some(lsp_types::DiagnosticSeverity::ERROR),
39                code: None,
40                code_description: None,
41                source: Some("tool".to_string()),
42                message: format!("Failed to apply changes: {}", err),
43                related_information: None,
44                tags: None,
45                data: None,
46            }];
47        }
48
49        // V_syn: From LSP diagnostics
50        if let Some(ref path) = self.last_written_file {
51            // PSP-5 Phase 4: look up LSP client by the node's owner_plugin
52            let node_plugin = self.graph[idx].owner_plugin.clone();
53            let lsp_key = if node_plugin.is_empty() || node_plugin == "unknown" {
54                "python".to_string() // legacy fallback
55            } else {
56                node_plugin
57            };
58
59            if let Some(client) = self.lsp_clients.get(&lsp_key) {
60                // Small delay to let LSP analyze the file
61                tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
62
63                let path_str = path.to_string_lossy().to_string();
64                let diagnostics = client.get_diagnostics(&path_str).await;
65
66                if !diagnostics.is_empty() {
67                    energy.v_syn = LspClient::calculate_syntactic_energy(&diagnostics);
68                    log::info!(
69                        "LSP found {} diagnostics, V_syn={:.2}",
70                        diagnostics.len(),
71                        energy.v_syn
72                    );
73                    self.emit_log(format!("πŸ” LSP found {} diagnostics:", diagnostics.len()));
74                    for d in &diagnostics {
75                        self.emit_log(format!(
76                            "   - [{}] {}",
77                            severity_to_str(d.severity),
78                            d.message
79                        ));
80                    }
81
82                    // Store diagnostics for correction prompt
83                    self.context.last_diagnostics = diagnostics;
84                } else {
85                    log::info!("LSP reports no errors (diagnostics vector is empty)");
86                }
87            } else {
88                log::debug!("No LSP client available for plugin '{}'", lsp_key);
89            }
90
91            // V_str: Check forbidden patterns in written file
92            let node = &self.graph[idx];
93            if !node.contract.forbidden_patterns.is_empty() {
94                if let Ok(content) = std::fs::read_to_string(path) {
95                    for pattern in &node.contract.forbidden_patterns {
96                        if content.contains(pattern) {
97                            energy.v_str += 0.5;
98                            log::warn!("Forbidden pattern found: '{}'", pattern);
99                            self.emit_log(format!("⚠️ Forbidden pattern: '{}'", pattern));
100                        }
101                    }
102                }
103            }
104
105            // PSP-5 Phase 9: Universal plugin verification for all node classes.
106            // Replaces the old weighted-test-only block that only ran for Integration nodes.
107            let node = &self.graph[idx];
108            if self.context.defer_tests {
109                self.emit_log("⏭️ Tests deferred (--defer-tests enabled)".to_string());
110            } else {
111                let plugin_name = node.owner_plugin.clone();
112                let verify_dir = self.effective_working_dir(idx);
113                let stages = verification_stages_for_node(node);
114
115                if !stages.is_empty() && !plugin_name.is_empty() && plugin_name != "unknown" {
116                    // Proactive dependency installation: install packages declared
117                    // in the architect's dependency_expectations before running
118                    // verification so the first build attempt has a better chance
119                    // of succeeding without reactive auto-repair.
120                    let dep_exp = node.dependency_expectations.clone();
121                    if !dep_exp.required_packages.is_empty() {
122                        self.emit_log(format!(
123                            "πŸ“¦ Pre-installing declared dependencies: {}",
124                            dep_exp.required_packages.join(", ")
125                        ));
126                        let installed = if plugin_name == "python" {
127                            Self::auto_install_python_deps(&dep_exp.required_packages, &verify_dir)
128                                .await
129                        } else {
130                            Self::auto_install_crate_deps(&dep_exp.required_packages, &verify_dir)
131                                .await
132                        };
133                        if installed > 0 {
134                            self.emit_log(format!(
135                                "πŸ“¦ Pre-installed {} declared package(s)",
136                                installed
137                            ));
138                        }
139                    }
140
141                    self.emit_log(format!(
142                        "πŸ”¬ Running verification ({} stages) for {} node '{}'...",
143                        stages.len(),
144                        node.node_class,
145                        node.node_id
146                    ));
147
148                    let mut vr = self
149                        .run_plugin_verification(&plugin_name, &stages, verify_dir.clone())
150                        .await;
151
152                    // Auto-dependency repair: if syntax/build failed, check if the
153                    // root cause is missing crate dependencies and auto-install them.
154                    if !vr.syntax_ok || !vr.build_ok {
155                        if let Some(ref raw) = vr.raw_output {
156                            let missing = Self::extract_missing_crates(raw);
157                            if !missing.is_empty() {
158                                self.emit_log(format!(
159                                    "πŸ“¦ Auto-installing missing dependencies: {}",
160                                    missing.join(", ")
161                                ));
162                                let dep_ok =
163                                    Self::auto_install_crate_deps(&missing, &verify_dir).await;
164                                if dep_ok > 0 {
165                                    self.emit_log(format!(
166                                        "πŸ“¦ Installed {} crate(s), re-running verification...",
167                                        dep_ok
168                                    ));
169                                    // Re-run verification now that deps are installed
170                                    vr = self
171                                        .run_plugin_verification(
172                                            &plugin_name,
173                                            &stages,
174                                            verify_dir.clone(),
175                                        )
176                                        .await;
177                                }
178                            }
179                        }
180                    }
181
182                    // Auto-dependency repair for Python: parse
183                    // ModuleNotFoundError / ImportError from test output and
184                    // install missing packages via `uv add`.
185                    if plugin_name == "python" && (!vr.syntax_ok || !vr.tests_ok) {
186                        // Collect raw output from all stage outcomes for
187                        // broader error detection (syntax check may report
188                        // import errors too).
189                        let all_output: String = vr
190                            .stage_outcomes
191                            .iter()
192                            .filter_map(|so| so.output.as_deref())
193                            .collect::<Vec<_>>()
194                            .join("\n");
195                        let combined = match vr.raw_output.as_deref() {
196                            Some(raw) => format!("{}\n{}", raw, all_output),
197                            None => all_output,
198                        };
199
200                        let missing = Self::extract_missing_python_modules(&combined);
201                        if !missing.is_empty() {
202                            self.emit_log(format!(
203                                "🐍 Auto-installing missing Python packages: {}",
204                                missing.join(", ")
205                            ));
206                            let dep_ok =
207                                Self::auto_install_python_deps(&missing, &verify_dir).await;
208                            if dep_ok > 0 {
209                                self.emit_log(format!(
210                                    "🐍 Installed {} package(s), re-running verification...",
211                                    dep_ok
212                                ));
213                                vr = self
214                                    .run_plugin_verification(
215                                        &plugin_name,
216                                        &stages,
217                                        verify_dir.clone(),
218                                    )
219                                    .await;
220                            }
221                        }
222                    }
223
224                    // Map verification result to energy components:
225                    // - Syntax fail β†’ V_syn (cap at 5.0, don't override tool-failure 10.0)
226                    if !vr.syntax_ok && energy.v_syn < 5.0 {
227                        energy.v_syn = 5.0;
228                    }
229                    // - Build fail β†’ V_syn (cap at 8.0, don't override higher)
230                    if !vr.build_ok && energy.v_syn < 8.0 {
231                        energy.v_syn = 8.0;
232                    }
233                    // - Test fail β†’ V_log (weighted calculation)
234                    if !vr.tests_ok && vr.tests_failed > 0 {
235                        let node = &self.graph[idx];
236                        if !node.contract.weighted_tests.is_empty() {
237                            // Use weighted test calculation if contract has weights
238                            let py_runner = PythonTestRunner::new(verify_dir);
239                            let test_results = TestResults {
240                                passed: vr.tests_passed,
241                                failed: vr.tests_failed,
242                                total: vr.tests_passed + vr.tests_failed,
243                                output: vr.raw_output.clone().unwrap_or_default(),
244                                failures: Vec::new(),
245                                run_succeeded: true,
246                                skipped: 0,
247                                duration_ms: 0,
248                            };
249                            energy.v_log = py_runner.calculate_v_log(&test_results, &node.contract);
250                        } else {
251                            // Simple: proportion of failures
252                            let total = (vr.tests_passed + vr.tests_failed) as f32;
253                            if total > 0.0 {
254                                energy.v_log = (vr.tests_failed as f32 / total) * 10.0;
255                            }
256                        }
257                    }
258                    // - Tests were expected but never ran (e.g. test compilation
259                    //   failed or test stage was skipped) β†’ treat as build failure.
260                    //   Without this, nodes with broken test files get V=0 and
261                    //   pass verification erroneously.
262                    if !vr.tests_ok
263                        && vr.tests_failed == 0
264                        && vr.tests_passed == 0
265                        && stages.contains(&perspt_core::plugin::VerifierStage::Test)
266                    {
267                        // Check raw output for compilation errors in test targets
268                        let test_compile_failed = vr
269                            .raw_output
270                            .as_deref()
271                            .or_else(|| {
272                                vr.stage_outcomes
273                                    .iter()
274                                    .find(|so| so.stage == "test")
275                                    .and_then(|so| so.output.as_deref())
276                            })
277                            .is_some_and(|o| {
278                                o.contains("error[E")
279                                    || o.contains("could not compile")
280                                    || o.contains("FAILED")
281                                    || o.contains("ModuleNotFoundError")
282                                    || o.contains("ImportError")
283                            });
284                        if test_compile_failed {
285                            log::warn!(
286                                "Test compilation failed for node '{}' β€” treating as build failure",
287                                self.graph[idx].node_id
288                            );
289                            if energy.v_syn < 8.0 {
290                                energy.v_syn = 8.0;
291                            }
292                        } else {
293                            // Tests didn't run but no obvious error β€” moderate penalty
294                            energy.v_log = 5.0;
295                            log::warn!(
296                                "Tests expected but did not produce results for node '{}'",
297                                self.graph[idx].node_id
298                            );
299                        }
300                    }
301                    // - Lint fail β†’ V_str penalty
302                    if !vr.lint_ok
303                        && self.context.verifier_strictness
304                            == perspt_core::types::VerifierStrictness::Strict
305                    {
306                        energy.v_str += 0.3;
307                    }
308
309                    // PSP-7: V_boot β€” bootstrap infrastructure failures.
310                    // Sensor degradation signals toolchain/environment issues
311                    // distinct from code quality captured by V_syn/V_log.
312                    // Only computed from the FINAL verification result (after
313                    // auto-repair has had its chance to fix missing deps).
314                    if vr.degraded && vr.stage_outcomes.is_empty() {
315                        // Fully degraded toolchain: no stages ran at all.
316                        energy.v_boot = 10.0;
317                        log::warn!(
318                            "V_boot = 10.0: toolchain fully degraded ({})",
319                            vr.degraded_reason.as_deref().unwrap_or("unknown")
320                        );
321                    }
322                    for so in &vr.stage_outcomes {
323                        match &so.sensor_status {
324                            perspt_core::types::SensorStatus::Unavailable { reason } => {
325                                energy.v_boot += 3.0;
326                                log::warn!(
327                                    "V_boot +3.0: sensor unavailable for stage '{}': {}",
328                                    so.stage,
329                                    reason
330                                );
331                            }
332                            perspt_core::types::SensorStatus::Fallback { reason, .. } => {
333                                energy.v_boot += 1.0;
334                                log::info!(
335                                    "V_boot +1.0: fallback sensor for stage '{}': {}",
336                                    so.stage,
337                                    reason
338                                );
339                            }
340                            perspt_core::types::SensorStatus::Available => {}
341                        }
342                    }
343
344                    // D1: Feed raw output into correction context
345                    if let Some(ref raw) = vr.raw_output {
346                        self.context.last_test_output = Some(raw.clone());
347                    }
348
349                    self.emit_log(format!("πŸ“Š Verification: {}", vr.summary));
350                }
351            }
352        }
353
354        let node = &self.graph[idx];
355        // Record energy in persistent ledger
356        if let Err(e) =
357            self.ledger
358                .record_energy(&node.node_id, &energy, energy.total(&node.contract))
359        {
360            log::error!("Failed to record energy: {}", e);
361        }
362
363        log::info!(
364            "Energy for {}: V_syn={:.2}, V_str={:.2}, V_log={:.2}, V_boot={:.2}, V_sheaf={:.2}, Total={:.2}",
365            node.node_id,
366            energy.v_syn,
367            energy.v_str,
368            energy.v_log,
369            energy.v_boot,
370            energy.v_sheaf,
371            energy.total(&node.contract)
372        );
373
374        // PSP-5 Phase 7: Emit enriched VerificationComplete event
375        {
376            let node = &self.graph[idx];
377            let total = energy.total(&node.contract);
378            let (
379                stage_outcomes,
380                degraded,
381                degraded_reasons,
382                summary,
383                lint_ok,
384                tests_passed,
385                tests_failed,
386            ) = if let Some(ref vr) = self.last_verification_result {
387                (
388                    vr.stage_outcomes.clone(),
389                    vr.degraded,
390                    vr.degraded_stage_reasons(),
391                    vr.summary.clone(),
392                    vr.lint_ok,
393                    vr.tests_passed,
394                    vr.tests_failed,
395                )
396            } else {
397                let diag_count = self.context.last_diagnostics.len();
398                (
399                    Vec::new(),
400                    false,
401                    Vec::new(),
402                    format!("V(x)={:.2} | {} diagnostics", total, diag_count),
403                    true,
404                    0,
405                    0,
406                )
407            };
408
409            self.emit_event(perspt_core::AgentEvent::VerificationComplete {
410                node_id: node.node_id.clone(),
411                syntax_ok: energy.v_syn == 0.0,
412                build_ok: energy.v_syn < 5.0,
413                tests_ok: energy.v_log == 0.0,
414                lint_ok,
415                diagnostics_count: self.context.last_diagnostics.len(),
416                tests_passed,
417                tests_failed,
418                energy: total,
419                energy_components: energy.clone(),
420                stage_outcomes,
421                degraded,
422                degraded_reasons,
423                summary,
424                node_class: node.node_class.to_string(),
425            });
426        }
427
428        Ok(energy)
429    }
430
431    /// PSP-5: Run plugin-driven verification for a node
432    ///
433    /// Uses the active language plugin's verifier profile to select commands
434    /// for syntax check, build, test, and lint stages. Delegates execution
435    /// to `TestRunnerTrait` implementations from `test_runner`.
436    ///
437    /// Each stage records a `StageOutcome` with `SensorStatus`, enabling
438    /// callers to detect fallback / unavailable sensors and block false
439    /// stability claims.
440    pub async fn run_plugin_verification(
441        &mut self,
442        plugin_name: &str,
443        allowed_stages: &[perspt_core::plugin::VerifierStage],
444        working_dir: std::path::PathBuf,
445    ) -> perspt_core::types::VerificationResult {
446        use perspt_core::plugin::VerifierStage;
447        use perspt_core::types::{SensorStatus, StageOutcome};
448
449        let registry = perspt_core::plugin::PluginRegistry::new();
450        let plugin = match registry.get(plugin_name) {
451            Some(p) => p,
452            None => {
453                return perspt_core::types::VerificationResult::degraded(format!(
454                    "Plugin '{}' not found",
455                    plugin_name
456                ));
457            }
458        };
459
460        let profile = plugin.verifier_profile();
461
462        // If fully degraded, report immediately
463        if profile.fully_degraded() {
464            return perspt_core::types::VerificationResult::degraded(format!(
465                "{} toolchain not available on host (all stages degraded)",
466                plugin.name()
467            ));
468        }
469
470        // Derive per-stage sensor status from the profile before moving it.
471        let sensor_status_for = |stage: VerifierStage,
472                                 profile: &perspt_core::plugin::VerifierProfile|
473         -> SensorStatus {
474            match profile.get(stage) {
475                Some(cap) if cap.available => SensorStatus::Available,
476                Some(cap) if cap.fallback_available => SensorStatus::Fallback {
477                    actual: cap
478                        .fallback_command
479                        .clone()
480                        .unwrap_or_else(|| "fallback".into()),
481                    reason: format!(
482                        "primary '{}' not found",
483                        cap.command.as_deref().unwrap_or("?")
484                    ),
485                },
486                Some(cap) => SensorStatus::Unavailable {
487                    reason: format!(
488                        "no tool for {} (tried '{}')",
489                        stage,
490                        cap.command.as_deref().unwrap_or("?")
491                    ),
492                },
493                None => SensorStatus::Unavailable {
494                    reason: format!("{} stage not declared by plugin", stage),
495                },
496            }
497        };
498
499        let syn_sensor = sensor_status_for(VerifierStage::SyntaxCheck, &profile);
500        let build_sensor = sensor_status_for(VerifierStage::Build, &profile);
501        let test_sensor = sensor_status_for(VerifierStage::Test, &profile);
502        let lint_sensor = sensor_status_for(VerifierStage::Lint, &profile);
503
504        let runner = test_runner::test_runner_for_profile(profile, working_dir);
505
506        let mut result = perspt_core::types::VerificationResult::default();
507
508        // PSP-5 Phase 9: Only run stages that are in the allowed filter.
509        // Short-circuit: if syntax fails, skip build/test/lint.
510        //                if build fails, skip test/lint.
511
512        // Syntax check
513        if allowed_stages.contains(&VerifierStage::SyntaxCheck) {
514            match runner.run_syntax_check().await {
515                Ok(r) => {
516                    result.syntax_ok = r.passed > 0 && r.failed == 0;
517                    if !result.syntax_ok && r.run_succeeded {
518                        result.diagnostics_count = r.output.lines().count();
519                        result.raw_output = Some(r.output.clone());
520                        self.emit_log(format!(
521                            "⚠️ Syntax check failed ({} diagnostics)",
522                            result.diagnostics_count
523                        ));
524                    } else if result.syntax_ok {
525                        self.emit_log("βœ… Syntax check passed".to_string());
526                    }
527                    result.stage_outcomes.push(StageOutcome {
528                        stage: VerifierStage::SyntaxCheck.to_string(),
529                        passed: result.syntax_ok,
530                        sensor_status: syn_sensor,
531                        output: Some(r.output),
532                    });
533                }
534                Err(e) => {
535                    log::warn!("Syntax check failed to run: {}", e);
536                    result.syntax_ok = false;
537                    result.stage_outcomes.push(StageOutcome {
538                        stage: VerifierStage::SyntaxCheck.to_string(),
539                        passed: false,
540                        sensor_status: SensorStatus::Unavailable {
541                            reason: format!("execution error: {}", e),
542                        },
543                        output: None,
544                    });
545                }
546            }
547
548            // Short-circuit: if syntax fails, skip remaining stages
549            if !result.syntax_ok {
550                self.emit_log("⏭️ Skipping build/test/lint β€” syntax check failed".to_string());
551                result.build_ok = false;
552                result.tests_ok = false;
553                self.finalize_verification_result(&mut result, plugin_name);
554                return result;
555            }
556        }
557
558        // Build check
559        if allowed_stages.contains(&VerifierStage::Build) {
560            match runner.run_build_check().await {
561                Ok(r) => {
562                    result.build_ok = r.passed > 0 && r.failed == 0;
563                    if result.build_ok {
564                        self.emit_log("βœ… Build passed".to_string());
565                    } else if r.run_succeeded {
566                        self.emit_log("⚠️ Build failed".to_string());
567                        result.raw_output = Some(r.output.clone());
568                    }
569                    result.stage_outcomes.push(StageOutcome {
570                        stage: VerifierStage::Build.to_string(),
571                        passed: result.build_ok,
572                        sensor_status: build_sensor,
573                        output: Some(r.output),
574                    });
575                }
576                Err(e) => {
577                    log::warn!("Build check failed to run: {}", e);
578                    result.build_ok = false;
579                    result.stage_outcomes.push(StageOutcome {
580                        stage: VerifierStage::Build.to_string(),
581                        passed: false,
582                        sensor_status: SensorStatus::Unavailable {
583                            reason: format!("execution error: {}", e),
584                        },
585                        output: None,
586                    });
587                }
588            }
589
590            // Short-circuit: if build fails, skip test/lint
591            if !result.build_ok {
592                self.emit_log("⏭️ Skipping test/lint β€” build failed".to_string());
593                result.tests_ok = false;
594                self.finalize_verification_result(&mut result, plugin_name);
595                return result;
596            }
597        }
598
599        // Tests
600        if allowed_stages.contains(&VerifierStage::Test) {
601            match runner.run_tests().await {
602                Ok(r) => {
603                    result.tests_ok = r.all_passed();
604                    result.tests_passed = r.passed;
605                    result.tests_failed = r.failed;
606
607                    if result.tests_ok {
608                        self.emit_log(format!("βœ… Tests passed ({})", plugin_name));
609                    } else {
610                        self.emit_log(format!("❌ Tests failed ({})", plugin_name));
611                        result.raw_output = Some(r.output.clone());
612                    }
613                    result.stage_outcomes.push(StageOutcome {
614                        stage: VerifierStage::Test.to_string(),
615                        passed: result.tests_ok,
616                        sensor_status: test_sensor,
617                        output: Some(r.output),
618                    });
619                }
620                Err(e) => {
621                    log::warn!("Test command failed to run: {}", e);
622                    result.tests_ok = false;
623                    result.stage_outcomes.push(StageOutcome {
624                        stage: VerifierStage::Test.to_string(),
625                        passed: false,
626                        sensor_status: SensorStatus::Unavailable {
627                            reason: format!("execution error: {}", e),
628                        },
629                        output: None,
630                    });
631                }
632            }
633        } else {
634            result.tests_ok = true; // Skip tests when not in allowed stages
635        }
636
637        // Lint (only when allowed AND in Strict mode)
638        if allowed_stages.contains(&VerifierStage::Lint)
639            && self.context.verifier_strictness == perspt_core::types::VerifierStrictness::Strict
640        {
641            match runner.run_lint().await {
642                Ok(r) => {
643                    result.lint_ok = r.passed > 0 && r.failed == 0;
644                    if result.lint_ok {
645                        self.emit_log("βœ… Lint passed".to_string());
646                    } else if r.run_succeeded {
647                        self.emit_log("⚠️ Lint issues found".to_string());
648                    }
649                    result.stage_outcomes.push(StageOutcome {
650                        stage: VerifierStage::Lint.to_string(),
651                        passed: result.lint_ok,
652                        sensor_status: lint_sensor,
653                        output: Some(r.output),
654                    });
655                }
656                Err(e) => {
657                    log::warn!("Lint command failed to run: {}", e);
658                    result.lint_ok = false;
659                    result.stage_outcomes.push(StageOutcome {
660                        stage: VerifierStage::Lint.to_string(),
661                        passed: false,
662                        sensor_status: SensorStatus::Unavailable {
663                            reason: format!("execution error: {}", e),
664                        },
665                        output: None,
666                    });
667                }
668            }
669        } else if !allowed_stages.contains(&VerifierStage::Lint) {
670            result.lint_ok = true; // Skip lint when not in allowed stages
671        } else {
672            result.lint_ok = true; // Skip lint in non-strict mode
673        }
674
675        self.finalize_verification_result(&mut result, plugin_name);
676        result
677    }
678
679    // =========================================================================
680    // Auto-dependency repair helpers
681    // =========================================================================
682
683    /// Parse `cargo check` / `cargo build` stderr and extract crate names that
684    /// are missing.  Handles patterns like:
685    ///   - `error[E0432]: unresolved import \`thiserror\``
686    ///   - `error[E0463]: can't find crate for \`serde\``
687    ///   - `use of undeclared crate or module \`clap\``
688    fn extract_missing_crates(output: &str) -> Vec<String> {
689        use std::collections::HashSet;
690
691        let mut crates: HashSet<String> = HashSet::new();
692
693        for line in output.lines() {
694            let lower = line.to_lowercase();
695
696            // Pattern: "use of undeclared crate or module `foo`"
697            if lower.contains("undeclared crate or module") {
698                if let Some(name) = Self::extract_backtick_ident(line) {
699                    if !name.contains("::") {
700                        crates.insert(name);
701                    }
702                }
703            }
704            // Pattern: "can't find crate for `foo`"
705            else if lower.contains("can't find crate for")
706                || lower.contains("cant find crate for")
707            {
708                if let Some(name) = Self::extract_backtick_ident(line) {
709                    crates.insert(name);
710                }
711            }
712            // Pattern: "unresolved import `thiserror`" at top level
713            else if lower.contains("unresolved import") {
714                if let Some(name) = Self::extract_backtick_ident(line) {
715                    let root = name.split("::").next().unwrap_or(&name).to_string();
716                    if root != "crate" && root != "self" && root != "super" {
717                        crates.insert(root);
718                    }
719                }
720            }
721        }
722
723        let builtins: HashSet<&str> = ["std", "core", "alloc", "proc_macro", "test"]
724            .iter()
725            .copied()
726            .collect();
727
728        crates
729            .into_iter()
730            .filter(|c| !builtins.contains(c.as_str()))
731            .collect()
732    }
733
734    /// Extract the first back-tick–quoted identifier from a line.
735    fn extract_backtick_ident(line: &str) -> Option<String> {
736        let start = line.find('`')? + 1;
737        let rest = &line[start..];
738        let end = rest.find('`')?;
739        let ident = &rest[..end];
740        if ident.is_empty() {
741            None
742        } else {
743            Some(ident.to_string())
744        }
745    }
746
747    /// Extract dependency commands from a correction LLM response.
748    /// PSP-7: Extract dependency commands from correction response, validated by plugin policy.
749    ///
750    /// Replaces the legacy hardcoded allowlist with plugin `dependency_command_policy()`.
751    pub(super) fn extract_commands_from_correction(
752        response: &str,
753        owner_plugin: &str,
754    ) -> Vec<String> {
755        let registry = perspt_core::plugin::PluginRegistry::new();
756        let plugin = registry.get(owner_plugin);
757
758        let mut commands = Vec::new();
759        let mut in_commands_section = false;
760        let mut in_code_block = false;
761
762        for line in response.lines() {
763            let trimmed = line.trim();
764
765            if trimmed.starts_with("Commands:")
766                || trimmed.starts_with("**Commands:")
767                || trimmed.starts_with("### Commands")
768            {
769                in_commands_section = true;
770                continue;
771            }
772
773            if in_commands_section {
774                if trimmed.starts_with("```") {
775                    in_code_block = !in_code_block;
776                    continue;
777                }
778
779                if !in_code_block
780                    && (trimmed.is_empty()
781                        || trimmed.starts_with('#')
782                        || trimmed.starts_with("File:")
783                        || trimmed.starts_with("Diff:"))
784                {
785                    in_commands_section = false;
786                    continue;
787                }
788
789                let cmd = trimmed
790                    .trim_start_matches("- ")
791                    .trim_start_matches("$ ")
792                    .trim();
793
794                if !cmd.is_empty() {
795                    let decision = plugin
796                        .map(|p| p.dependency_command_policy(cmd))
797                        .unwrap_or(perspt_core::types::CommandPolicyDecision::Allow);
798
799                    match decision {
800                        perspt_core::types::CommandPolicyDecision::Allow
801                        | perspt_core::types::CommandPolicyDecision::RequireApproval => {
802                            commands.push(cmd.to_string());
803                        }
804                        perspt_core::types::CommandPolicyDecision::Deny => {
805                            log::warn!(
806                                "Command '{}' denied by plugin policy for '{}'",
807                                cmd,
808                                owner_plugin
809                            );
810                        }
811                    }
812                }
813            }
814        }
815
816        commands
817    }
818
819    /// Run `cargo add <crate>` for each missing crate. Returns count of successes.
820    async fn auto_install_crate_deps(crates: &[String], working_dir: &std::path::Path) -> usize {
821        let mut installed = 0usize;
822        for krate in crates {
823            log::info!("Auto-installing crate: cargo add {}", krate);
824            let result = tokio::process::Command::new("cargo")
825                .args(["add", krate])
826                .current_dir(working_dir)
827                .stdout(std::process::Stdio::piped())
828                .stderr(std::process::Stdio::piped())
829                .output()
830                .await;
831
832            match result {
833                Ok(output) if output.status.success() => {
834                    log::info!("Successfully installed crate: {}", krate);
835                    installed += 1;
836                }
837                Ok(output) => {
838                    let stderr = String::from_utf8_lossy(&output.stderr);
839                    log::warn!("Failed to install crate {}: {}", krate, stderr);
840                }
841                Err(e) => {
842                    log::warn!("Failed to run cargo add {}: {}", krate, e);
843                }
844            }
845        }
846        installed
847    }
848
849    // =========================================================================
850    // Python auto-dependency repair helpers (uv-first)
851    // =========================================================================
852
853    /// Parse Python test/import output and extract module names that are missing.
854    ///
855    /// Handles patterns like:
856    ///   - `ModuleNotFoundError: No module named 'httpx'`
857    ///   - `ImportError: cannot import name 'foo' from 'bar'`
858    ///   - `E   ModuleNotFoundError: No module named 'pydantic'`
859    pub(super) fn extract_missing_python_modules(output: &str) -> Vec<String> {
860        use std::collections::HashSet;
861
862        let mut modules: HashSet<String> = HashSet::new();
863
864        for line in output.lines() {
865            let trimmed = line.trim().trim_start_matches("E").trim();
866
867            // Pattern: "ModuleNotFoundError: No module named 'foo'"
868            // Also matches: "ModuleNotFoundError: No module named 'foo.bar'"
869            // Can appear anywhere in the line (e.g. after FAILED test_x.py::test - ...)
870            if trimmed.contains("ModuleNotFoundError: No module named ") {
871                // Extract the quoted module name after "No module named "
872                if let Some(pos) = trimmed.find("No module named ") {
873                    let after = &trimmed[pos + "No module named ".len()..];
874                    let name = after.trim().trim_matches('\'').trim_matches('"');
875                    let root = name.split('.').next().unwrap_or(name);
876                    if !root.is_empty() {
877                        modules.insert(root.to_string());
878                    }
879                }
880            }
881            // Pattern: "ImportError: cannot import name 'X' from 'Y'"
882            // or "ImportError: No module named 'X'"
883            else if trimmed.contains("ImportError") && trimmed.contains("No module named") {
884                if let Some(start) = trimmed.find('\'') {
885                    let rest = &trimmed[start + 1..];
886                    if let Some(end) = rest.find('\'') {
887                        let name = &rest[..end];
888                        let root = name.split('.').next().unwrap_or(name);
889                        if !root.is_empty() {
890                            modules.insert(root.to_string());
891                        }
892                    }
893                }
894            }
895        }
896
897        // Filter out standard library modules that are always present
898        let stdlib: HashSet<&str> = [
899            "os",
900            "sys",
901            "json",
902            "re",
903            "math",
904            "datetime",
905            "collections",
906            "itertools",
907            "functools",
908            "pathlib",
909            "typing",
910            "abc",
911            "io",
912            "unittest",
913            "logging",
914            "argparse",
915            "sqlite3",
916            "csv",
917            "hashlib",
918            "tempfile",
919            "shutil",
920            "copy",
921            "contextlib",
922            "dataclasses",
923            "enum",
924            "textwrap",
925            "importlib",
926            "inspect",
927            "traceback",
928            "subprocess",
929            "threading",
930            "multiprocessing",
931            "asyncio",
932            "socket",
933            "http",
934            "urllib",
935            "xml",
936            "html",
937            "email",
938            "string",
939            "struct",
940            "array",
941            "queue",
942            "heapq",
943            "bisect",
944            "pprint",
945            "decimal",
946            "fractions",
947            "random",
948            "secrets",
949            "time",
950            "calendar",
951            "zlib",
952            "gzip",
953            "zipfile",
954            "tarfile",
955            "glob",
956            "fnmatch",
957            "stat",
958            "fileinput",
959            "codecs",
960            "uuid",
961            "base64",
962            "binascii",
963            "pickle",
964            "shelve",
965            "dbm",
966            "platform",
967            "signal",
968            "mmap",
969            "ctypes",
970            "configparser",
971            "tomllib",
972            "warnings",
973            "weakref",
974            "types",
975            "operator",
976            "numbers",
977            "__future__",
978        ]
979        .iter()
980        .copied()
981        .collect();
982
983        modules
984            .into_iter()
985            .filter(|m| !stdlib.contains(m.as_str()))
986            .collect()
987    }
988
989    /// Map a Python import name to its PyPI package name.
990    ///
991    /// Most packages use the same name for import and install, but some
992    /// notable exceptions exist. We handle the common ones here.
993    pub(super) fn python_import_to_package(import_name: &str) -> &str {
994        match import_name {
995            "PIL" | "pil" => "pillow",
996            "cv2" => "opencv-python",
997            "yaml" => "pyyaml",
998            "bs4" => "beautifulsoup4",
999            "sklearn" => "scikit-learn",
1000            "attr" | "attrs" => "attrs",
1001            "dateutil" => "python-dateutil",
1002            "dotenv" => "python-dotenv",
1003            "gi" => "PyGObject",
1004            "serial" => "pyserial",
1005            "usb" => "pyusb",
1006            "wx" => "wxPython",
1007            "lxml" => "lxml",
1008            "Crypto" => "pycryptodome",
1009            "jose" => "python-jose",
1010            "jwt" => "PyJWT",
1011            "magic" => "python-magic",
1012            "docx" => "python-docx",
1013            "pptx" => "python-pptx",
1014            "git" => "gitpython",
1015            "psycopg2" => "psycopg2-binary",
1016            other => other,
1017        }
1018    }
1019
1020    /// Run `uv add <package>` for each missing Python module. Returns count of successes.
1021    async fn auto_install_python_deps(modules: &[String], working_dir: &std::path::Path) -> usize {
1022        let mut installed = 0usize;
1023        for module in modules {
1024            let package = Self::python_import_to_package(module);
1025            log::info!("Auto-installing Python package: uv add {}", package);
1026            let result = tokio::process::Command::new("uv")
1027                .args(["add", package])
1028                .current_dir(working_dir)
1029                .env_remove("VIRTUAL_ENV")
1030                .stdout(std::process::Stdio::piped())
1031                .stderr(std::process::Stdio::piped())
1032                .output()
1033                .await;
1034
1035            match result {
1036                Ok(output) if output.status.success() => {
1037                    log::info!("Successfully installed Python package: {}", package);
1038                    installed += 1;
1039                }
1040                Ok(output) => {
1041                    let stderr = String::from_utf8_lossy(&output.stderr);
1042                    log::warn!("Failed to install Python package {}: {}", package, stderr);
1043                }
1044                Err(e) => {
1045                    log::warn!("Failed to run uv add {}: {}", package, e);
1046                }
1047            }
1048        }
1049
1050        // Always sync after adding dependencies to ensure venv is up-to-date
1051        if installed > 0 {
1052            log::info!("Running uv sync --dev after dependency install...");
1053            let _ = tokio::process::Command::new("uv")
1054                .args(["sync", "--dev"])
1055                .current_dir(working_dir)
1056                .env_remove("VIRTUAL_ENV")
1057                .stdout(std::process::Stdio::piped())
1058                .stderr(std::process::Stdio::piped())
1059                .output()
1060                .await;
1061        }
1062
1063        installed
1064    }
1065
1066    /// Normalize a dependency command to its uv-first equivalent.
1067    ///
1068    /// Converts generic pip/pip3/python -m pip install commands to `uv add`,
1069    /// leaving already-correct uv commands and non-Python commands unchanged.
1070    pub(super) fn normalize_command_to_uv(command: &str) -> String {
1071        let trimmed = command.trim();
1072
1073        // pip install foo β†’ uv add foo
1074        // pip3 install foo β†’ uv add foo
1075        // python -m pip install foo β†’ uv add foo
1076        // python3 -m pip install foo β†’ uv add foo
1077        let pip_install_prefixes = [
1078            "pip install ",
1079            "pip3 install ",
1080            "python -m pip install ",
1081            "python3 -m pip install ",
1082        ];
1083        for prefix in &pip_install_prefixes {
1084            if let Some(rest) = trimmed.strip_prefix(prefix) {
1085                let packages = rest.trim();
1086                if packages.is_empty() {
1087                    return command.to_string();
1088                }
1089                // Strip -r/--requirement flags (uv add doesn't support those directly)
1090                if packages.starts_with("-r ") || packages.starts_with("--requirement ") {
1091                    return format!("uv pip install {}", packages);
1092                }
1093                return format!("uv add {}", packages);
1094            }
1095        }
1096
1097        // pip install -e . β†’ uv pip install -e .
1098        if trimmed.starts_with("pip install -") || trimmed.starts_with("pip3 install -") {
1099            return format!("uv {}", trimmed);
1100        }
1101
1102        command.to_string()
1103    }
1104
1105    /// PSP-5 Phase 9: Finalize verification result β€” mark degraded, emit events, build summary.
1106    fn finalize_verification_result(
1107        &mut self,
1108        result: &mut perspt_core::types::VerificationResult,
1109        plugin_name: &str,
1110    ) {
1111        if result.has_degraded_stages() {
1112            result.degraded = true;
1113            let reasons = result.degraded_stage_reasons();
1114            result.degraded_reason = Some(reasons.join("; "));
1115
1116            // Emit per-stage SensorFallback events
1117            for outcome in &result.stage_outcomes {
1118                if let perspt_core::types::SensorStatus::Fallback { actual, reason } =
1119                    &outcome.sensor_status
1120                {
1121                    self.emit_event(perspt_core::AgentEvent::SensorFallback {
1122                        node_id: plugin_name.to_string(),
1123                        stage: outcome.stage.clone(),
1124                        primary: reason.clone(),
1125                        actual: actual.clone(),
1126                        reason: reason.clone(),
1127                    });
1128                }
1129            }
1130        }
1131
1132        // Store result for convergence-time degraded check
1133        self.last_verification_result = Some(result.clone());
1134
1135        // Build summary
1136        result.summary = format!(
1137            "{}: syntax={}, build={}, tests={}, lint={}{}",
1138            plugin_name,
1139            if result.syntax_ok { "βœ…" } else { "❌" },
1140            if result.build_ok { "βœ…" } else { "❌" },
1141            if result.tests_ok { "βœ…" } else { "❌" },
1142            if result.lint_ok { "βœ…" } else { "⏭️" },
1143            if result.degraded { " (degraded)" } else { "" },
1144        );
1145    }
1146}
1147
1148/// Convert diagnostic severity to string
1149pub(super) fn severity_to_str(severity: Option<lsp_types::DiagnosticSeverity>) -> &'static str {
1150    match severity {
1151        Some(lsp_types::DiagnosticSeverity::ERROR) => "ERROR",
1152        Some(lsp_types::DiagnosticSeverity::WARNING) => "WARNING",
1153        Some(lsp_types::DiagnosticSeverity::INFORMATION) => "INFO",
1154        Some(lsp_types::DiagnosticSeverity::HINT) => "HINT",
1155        Some(_) => "OTHER",
1156        None => "UNKNOWN",
1157    }
1158}
1159
1160/// PSP-5 Phase 9: Determine which verification stages to run based on NodeClass.
1161///
1162/// - **Interface**: SyntaxCheck only (signatures/schemas)
1163/// - **Implementation**: SyntaxCheck + Build (+ Test if weighted_tests non-empty
1164///   OR output targets include test files)
1165/// - **Integration**: Full pipeline (SyntaxCheck + Build + Test + Lint)
1166pub(super) fn verification_stages_for_node(
1167    node: &SRBNNode,
1168) -> Vec<perspt_core::plugin::VerifierStage> {
1169    use perspt_core::plugin::VerifierStage;
1170    match node.node_class {
1171        perspt_core::types::NodeClass::Interface => {
1172            vec![VerifierStage::SyntaxCheck]
1173        }
1174        perspt_core::types::NodeClass::Implementation => {
1175            let mut stages = vec![VerifierStage::SyntaxCheck, VerifierStage::Build];
1176            // Include Test stage if the node has weighted tests OR if the
1177            // node's output targets include test files.  Without this, nodes
1178            // that produce test files (tests/*.rs, test_*.py, *.test.ts, etc.)
1179            // only get SyntaxCheck+Build which don't compile/run test targets.
1180            let has_test_outputs = node.output_targets.iter().any(|p| {
1181                let s = p.to_string_lossy();
1182                // Check the filename (last component) for test patterns rather
1183                // than the full path, to avoid false positives from directory
1184                // names like "test_seismic/" matching "/test_".
1185                let filename = p
1186                    .file_name()
1187                    .map(|f| f.to_string_lossy())
1188                    .unwrap_or_default();
1189                s.contains("/tests/")
1190                    || filename.starts_with("test_")
1191                    || filename.contains(".test.")
1192                    || filename.contains(".spec.")
1193                    || filename.ends_with("_test.rs")
1194                    || filename.ends_with("_test.py")
1195                    || filename.ends_with("_tests.rs")
1196                    || filename.ends_with("_tests.py")
1197            });
1198            if !node.contract.weighted_tests.is_empty() || has_test_outputs {
1199                stages.push(VerifierStage::Test);
1200            }
1201            stages
1202        }
1203        perspt_core::types::NodeClass::Integration => {
1204            vec![
1205                VerifierStage::SyntaxCheck,
1206                VerifierStage::Build,
1207                VerifierStage::Test,
1208                VerifierStage::Lint,
1209            ]
1210        }
1211    }
1212}