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}