Skip to main content

perspt_core/plugin/
contract.rs

1use super::*;
2
3/// LSP Configuration for a language
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct LspConfig {
6    /// LSP server binary name
7    pub server_binary: String,
8    /// Arguments to pass to the server
9    pub args: Vec<String>,
10    /// Language ID for textDocument/didOpen
11    pub language_id: String,
12}
13
14// =============================================================================
15// PSP-5 Phase 4: Verifier Capability Declarations
16// =============================================================================
17
18/// Verification stage in the plugin-driven pipeline.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum VerifierStage {
21    /// Syntax / type check (e.g. `cargo check`, `uvx ty check .`)
22    SyntaxCheck,
23    /// Build step (e.g. `cargo build`, `npm run build`)
24    Build,
25    /// Test execution (e.g. `cargo test`, `uv run pytest`)
26    Test,
27    /// Lint pass (e.g. `cargo clippy`, `uv run ruff check .`)
28    Lint,
29    /// Formatting check (e.g. `cargo fmt --check`). Declared by plugins
30    /// whose formatter has a check form; gates only when the run enables
31    /// `require_format`.
32    Format,
33}
34
35impl VerifierStage {
36    /// The canonical stage name used by `HardGatePolicy::required_stages`
37    /// declarations. Domains declare `"syntax"`, not the `Display` form
38    /// `"syntax_check"`; this mapping is the single source for both sides
39    /// (PSP-10 Phase 1).
40    pub fn policy_name(&self) -> &'static str {
41        match self {
42            VerifierStage::SyntaxCheck => "syntax",
43            VerifierStage::Build => "build",
44            VerifierStage::Test => "test",
45            VerifierStage::Lint => "lint",
46            VerifierStage::Format => "format",
47        }
48    }
49}
50
51impl std::fmt::Display for VerifierStage {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            VerifierStage::SyntaxCheck => write!(f, "syntax_check"),
55            VerifierStage::Build => write!(f, "build"),
56            VerifierStage::Test => write!(f, "test"),
57            VerifierStage::Lint => write!(f, "lint"),
58            VerifierStage::Format => write!(f, "format"),
59        }
60    }
61}
62
63/// A single verifier sensor: one stage of the verification pipeline.
64///
65/// Each capability independently declares its command, host-tool availability,
66/// and optional fallback. This replaces the coarse single `host_tool_available()`
67/// check with per-sensor probing.
68#[derive(Debug, Clone)]
69pub struct VerifierCapability {
70    /// Which stage this capability covers.
71    pub stage: VerifierStage,
72    /// Primary command to execute (None if this stage is not supported).
73    pub command: Option<String>,
74    /// Whether the primary command's host tool is available on this machine.
75    pub available: bool,
76    /// Fallback command when the primary tool is unavailable.
77    pub fallback_command: Option<String>,
78    /// Whether the fallback tool is available.
79    pub fallback_available: bool,
80}
81
82impl VerifierCapability {
83    /// True if either the primary or fallback tool is available.
84    pub fn any_available(&self) -> bool {
85        self.available || self.fallback_available
86    }
87
88    /// The best available command, preferring primary over fallback.
89    pub fn effective_command(&self) -> Option<&str> {
90        if self.available {
91            self.command.as_deref()
92        } else if self.fallback_available {
93            self.fallback_command.as_deref()
94        } else {
95            None
96        }
97    }
98}
99
100/// LSP availability and fallback for a plugin.
101#[derive(Debug, Clone)]
102pub struct LspCapability {
103    /// Primary LSP configuration.
104    pub primary: LspConfig,
105    /// Whether the primary LSP binary is available on the host.
106    pub primary_available: bool,
107    /// Fallback LSP configuration (if any).
108    pub fallback: Option<LspConfig>,
109    /// Whether the fallback binary is available.
110    pub fallback_available: bool,
111}
112
113impl LspCapability {
114    /// Return the best available LSP config, preferring primary.
115    pub fn effective_config(&self) -> Option<&LspConfig> {
116        if self.primary_available {
117            Some(&self.primary)
118        } else if self.fallback_available {
119            self.fallback.as_ref()
120        } else {
121            None
122        }
123    }
124}
125
126/// Complete verifier profile for a plugin.
127///
128/// Bundles all per-sensor capabilities and LSP availability into one
129/// inspectable structure. Built by `LanguagePlugin::verifier_profile()`.
130#[derive(Debug, Clone)]
131pub struct VerifierProfile {
132    /// Name of the plugin that produced this profile.
133    pub plugin_name: String,
134    /// Per-stage verifier capabilities.
135    pub capabilities: Vec<VerifierCapability>,
136    /// LSP availability and fallback.
137    pub lsp: LspCapability,
138}
139
140impl VerifierProfile {
141    /// Get the capability for a given stage, if declared.
142    pub fn get(&self, stage: VerifierStage) -> Option<&VerifierCapability> {
143        self.capabilities.iter().find(|c| c.stage == stage)
144    }
145
146    /// Stages that have at least one available tool (primary or fallback).
147    pub fn available_stages(&self) -> Vec<VerifierStage> {
148        self.capabilities
149            .iter()
150            .filter(|c| c.any_available())
151            .map(|c| c.stage)
152            .collect()
153    }
154
155    /// True when every declared stage has zero available tools.
156    pub fn fully_degraded(&self) -> bool {
157        self.capabilities.iter().all(|c| !c.any_available())
158    }
159}
160
161// =============================================================================
162// Utility: host binary probe
163// =============================================================================
164
165/// Check whether a given binary name is available on the host PATH.
166///
167/// Runs `<binary> --version` silently; returns `true` if the process exits
168/// successfully. Used by plugins for per-sensor host-tool probing.
169/// A governed dependency-management action (Gate J).
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum DependencyAction {
172    Add,
173    Remove,
174    Update,
175}
176
177impl DependencyAction {
178    pub fn parse(value: &str) -> Option<Self> {
179        match value {
180            "add" => Some(Self::Add),
181            "remove" => Some(Self::Remove),
182            "update" => Some(Self::Update),
183            _ => None,
184        }
185    }
186}
187
188pub fn host_binary_available(binary: &str) -> bool {
189    std::process::Command::new(binary)
190        .arg("--version")
191        .stdout(std::process::Stdio::null())
192        .stderr(std::process::Stdio::null())
193        .status()
194        .map(|s| s.success())
195        .unwrap_or(false)
196}
197
198/// Options for project initialization
199#[derive(Debug, Clone, Default)]
200pub struct InitOptions {
201    /// Project name
202    pub name: String,
203    /// Whether to use a specific package manager (e.g., "poetry", "pdm", "npm", "pnpm")
204    pub package_manager: Option<String>,
205    /// Additional flags
206    pub flags: Vec<String>,
207    /// Whether the target directory is empty
208    pub is_empty_dir: bool,
209}
210
211/// Action to take for project initialization or tooling sync
212#[derive(Debug, Clone)]
213pub enum ProjectAction {
214    /// Execute a shell command
215    ExecCommand {
216        /// The command to run
217        command: String,
218        /// Human-readable description of what this command does
219        description: String,
220    },
221    /// No action needed
222    NoAction,
223}
224
225/// A plugin for a specific programming language
226///
227/// PSP-5 expands this trait beyond init/test/run to a full capability-based
228/// runtime contract that governs detection, verification, LSP, and ownership.
229pub trait LanguagePlugin: Send + Sync {
230    /// Name of the language
231    fn name(&self) -> &str;
232
233    /// File extensions this plugin handles
234    fn extensions(&self) -> &[&str];
235
236    /// Key files that identify this language (e.g., Cargo.toml, pyproject.toml)
237    fn key_files(&self) -> &[&str];
238
239    /// Detect if this plugin should handle the given project directory
240    fn detect(&self, path: &Path) -> bool {
241        // Check for key files
242        for key_file in self.key_files() {
243            if path.join(key_file).exists() {
244                return true;
245            }
246        }
247
248        // Check for files with handled extensions
249        if let Ok(entries) = std::fs::read_dir(path) {
250            for entry in entries.flatten() {
251                if let Some(ext) = entry.path().extension() {
252                    let ext_str = ext.to_string_lossy();
253                    if self.extensions().iter().any(|e| *e == ext_str) {
254                        return true;
255                    }
256                }
257            }
258        }
259
260        false
261    }
262
263    /// Get the LSP configuration for this language
264    fn get_lsp_config(&self) -> LspConfig;
265
266    /// Get the action to initialize a new project (greenfield)
267    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction;
268
269    /// Check if an existing project needs tooling sync (e.g., uv sync, cargo fetch)
270    fn check_tooling_action(&self, path: &Path) -> ProjectAction;
271
272    /// Get the command to initialize a new project
273    /// DEPRECATED: Use get_init_action instead
274    fn init_command(&self, opts: &InitOptions) -> String;
275
276    /// Get the command to run tests
277    fn test_command(&self) -> String;
278
279    /// The test command narrowed to an already-sanitized name filter. The
280    /// default ignores the filter; plugins with a native filter form
281    /// override it. Acceptance gates always use the unfiltered
282    /// [`test_command`](Self::test_command) — only interactive `run_test`
283    /// calls narrow.
284    fn test_command_with_filter(&self, _filter: Option<&str>) -> String {
285        self.test_command()
286    }
287
288    /// Get the command to run the project (for verification)
289    fn run_command(&self) -> String;
290
291    /// Get the command to run the project in a specific directory.
292    ///
293    /// Override this to inspect pyproject.toml, Cargo.toml, etc. and return a
294    /// more appropriate run command than the generic default.
295    fn run_command_for_dir(&self, _path: &Path) -> String {
296        self.run_command()
297    }
298
299    // =========================================================================
300    // PSP-5: Capability-Based Runtime Contract
301    // =========================================================================
302
303    /// Get the syntax/type check command (e.g., `cargo check`, `uvx ty check .`)
304    ///
305    /// Returns None if the plugin has no syntax check command (uses LSP only).
306    fn syntax_check_command(&self) -> Option<String> {
307        None
308    }
309
310    /// Get the build command (e.g., `cargo build`, `npm run build`)
311    ///
312    /// Returns None if the language doesn't have a separate build step.
313    fn build_command(&self) -> Option<String> {
314        None
315    }
316
317    /// Get the lint command (e.g., `cargo clippy -- -D warnings`)
318    ///
319    /// Used only in VerifierStrictness::Strict mode.
320    fn lint_command(&self) -> Option<String> {
321        None
322    }
323
324    /// The governed formatter command (`run_formatter`; e.g. `cargo fmt`).
325    fn format_command(&self) -> Option<String> {
326        None
327    }
328
329    /// The formatter's non-mutating check form (e.g. `cargo fmt --check`),
330    /// declared as the `format` verifier stage when present.
331    fn format_check_command(&self) -> Option<String> {
332        None
333    }
334
335    /// File glob patterns this plugin owns (e.g., `["*.rs", "Cargo.toml"]`)
336    ///
337    /// Used for node ownership matching in multi-language repos.
338    fn file_ownership_patterns(&self) -> &[&str] {
339        self.extensions()
340    }
341
342    /// PSP-5 Phase 2: Check if a file path belongs to this plugin's ownership domain
343    ///
344    /// Uses `file_ownership_patterns()` for suffix/extension matching.
345    fn owns_file(&self, path: &str) -> bool {
346        let path_lower = path.to_lowercase();
347        self.file_ownership_patterns().iter().any(|pattern| {
348            let pattern = pattern.trim_start_matches('*');
349            path_lower.ends_with(pattern)
350        })
351    }
352
353    /// Whether an existing file contributes executable test-oracle semantics.
354    /// The governed candidate runs a second verifier pass with source
355    /// pre-images restored whenever one of these files is modified.
356    fn is_test_file(&self, _path: &str) -> bool {
357        false
358    }
359
360    /// Check if the host has the required build tools available
361    ///
362    /// Returns true if the plugin's primary toolchain is installed and callable.
363    /// When false, the runtime enters degraded-validation mode.
364    fn host_tool_available(&self) -> bool {
365        true
366    }
367
368    /// Required host binaries for this plugin, grouped by role.
369    ///
370    /// Each entry is `(binary_name, role_description, install_hint)`.
371    /// The orchestrator checks these before init and emits install directions
372    /// for any that are missing.
373    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
374        Vec::new()
375    }
376
377    /// Get fallback LSP config when primary is unavailable
378    fn lsp_fallback(&self) -> Option<LspConfig> {
379        None
380    }
381
382    // =========================================================================
383    // PSP-5 Phase 4: Verifier Profile Assembly
384    // =========================================================================
385
386    /// Build a complete verifier profile by probing each capability.
387    ///
388    /// The default implementation auto-assembles from the existing
389    /// `syntax_check_command()`, `build_command()`, `test_command()`,
390    /// `lint_command()`, and `host_tool_available()` methods.
391    ///
392    /// Plugins override this method to provide per-sensor probing
393    /// with distinct fallback commands and independent availability checks.
394    fn verifier_profile(&self) -> VerifierProfile {
395        let tool_available = self.host_tool_available();
396
397        let mut capabilities = Vec::new();
398
399        if let Some(cmd) = self.syntax_check_command() {
400            capabilities.push(VerifierCapability {
401                stage: VerifierStage::SyntaxCheck,
402                command: Some(cmd),
403                available: tool_available,
404                fallback_command: None,
405                fallback_available: false,
406            });
407        }
408
409        if let Some(cmd) = self.build_command() {
410            capabilities.push(VerifierCapability {
411                stage: VerifierStage::Build,
412                command: Some(cmd),
413                available: tool_available,
414                fallback_command: None,
415                fallback_available: false,
416            });
417        }
418
419        // Test always has a command (test_command is required)
420        capabilities.push(VerifierCapability {
421            stage: VerifierStage::Test,
422            command: Some(self.test_command()),
423            available: tool_available,
424            fallback_command: None,
425            fallback_available: false,
426        });
427
428        if let Some(cmd) = self.lint_command() {
429            capabilities.push(VerifierCapability {
430                stage: VerifierStage::Lint,
431                command: Some(cmd),
432                available: tool_available,
433                fallback_command: None,
434                fallback_available: false,
435            });
436        }
437
438        let primary_config = self.get_lsp_config();
439        let primary_available = host_binary_available(&primary_config.server_binary);
440        let fallback = self.lsp_fallback();
441        let fallback_available = fallback
442            .as_ref()
443            .map(|f| host_binary_available(&f.server_binary))
444            .unwrap_or(false);
445
446        VerifierProfile {
447            plugin_name: self.name().to_string(),
448            capabilities,
449            lsp: LspCapability {
450                primary: primary_config,
451                primary_available,
452                fallback,
453                fallback_available,
454            },
455        }
456    }
457
458    // =========================================================================
459    // PSP-7: Correction Contract
460    // =========================================================================
461
462    /// Legal support files that the LLM is allowed to create beyond declared
463    /// `output_files` (e.g., `Cargo.toml` for Rust, `__init__.py` for Python).
464    ///
465    /// These are files that commonly accompany code generation but are not
466    /// explicitly listed in the plan. The typed parse pipeline's Layer E
467    /// uses this to accept known auxiliary files without flagging them as
468    /// ownership violations.
469    fn legal_support_files(&self) -> &[&str] {
470        &[]
471    }
472
473    /// Policy for manifest file mutations produced by the LLM.
474    ///
475    /// Returns whether a given manifest path may be modified. Plugins can
476    /// deny mutations to key files (e.g., root `Cargo.toml` in a workspace)
477    /// while allowing leaf-level manifest edits.
478    fn manifest_mutation_policy(
479        &self,
480        _manifest_path: &str,
481    ) -> crate::types::ManifestMutationPolicy {
482        crate::types::ManifestMutationPolicy::Allow
483    }
484
485    /// The command sequence realizing a governed dependency action, or
486    /// empty when this plugin does not support dependency mutation. Every
487    /// returned command must itself satisfy `dependency_command_policy`,
488    /// and the handler re-checks it (fail closed).
489    fn dependency_commands(
490        &self,
491        _action: DependencyAction,
492        _packages: &[String],
493        _dev: bool,
494    ) -> Vec<String> {
495        Vec::new()
496    }
497
498    /// Workspace-relative manifest and lockfile paths a dependency command
499    /// may mutate — the promotable footprint of `mutate_dependencies`.
500    fn dependency_files(&self) -> Vec<String> {
501        Vec::new()
502    }
503
504    /// Policy for dependency-management commands emitted by the LLM.
505    ///
506    /// Replaces the hardcoded command allowlist in the correction pipeline.
507    /// Each command string (e.g., `"cargo add serde"`) is checked against
508    /// this policy before execution.
509    fn dependency_command_policy(&self, _command: &str) -> crate::types::CommandPolicyDecision {
510        crate::types::CommandPolicyDecision::Allow
511    }
512
513    /// Glob patterns that identify test files for this language.
514    ///
515    /// Used by plan validation to infer that test-type tasks should depend on
516    /// the code tasks whose output files match these patterns' sibling sources.
517    fn test_file_patterns(&self) -> &[&str] {
518        &[]
519    }
520}