syncable_cli/agent/tools/
hadolint.rs

1//! Hadolint tool - Native Dockerfile linting using Rig's Tool trait
2//!
3//! Provides native Dockerfile linting without requiring the external hadolint binary.
4//! Implements hadolint rules with full pragma support.
5//!
6//! Output is optimized for AI agent decision-making with:
7//! - Categorized issues (security, best-practice, maintainability, performance)
8//! - Priority rankings (critical, high, medium, low)
9//! - Actionable fix recommendations
10//! - Rule documentation links
11
12use rig::completion::ToolDefinition;
13use rig::tool::Tool;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use std::path::PathBuf;
17
18use crate::analyzer::hadolint::{lint, lint_file, HadolintConfig, LintResult, Severity};
19
20/// Arguments for the hadolint tool
21#[derive(Debug, Deserialize)]
22pub struct HadolintArgs {
23    /// Path to Dockerfile (relative to project root) or inline content
24    #[serde(default)]
25    pub dockerfile: Option<String>,
26
27    /// Inline Dockerfile content to lint (alternative to path)
28    #[serde(default)]
29    pub content: Option<String>,
30
31    /// Rules to ignore (e.g., ["DL3008", "DL3013"])
32    #[serde(default)]
33    pub ignore: Vec<String>,
34
35    /// Minimum severity threshold: "error", "warning", "info", "style"
36    #[serde(default)]
37    pub threshold: Option<String>,
38}
39
40/// Error type for hadolint tool
41#[derive(Debug, thiserror::Error)]
42#[error("Hadolint error: {0}")]
43pub struct HadolintError(String);
44
45/// Tool to lint Dockerfiles natively
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct HadolintTool {
48    project_path: PathBuf,
49}
50
51impl HadolintTool {
52    pub fn new(project_path: PathBuf) -> Self {
53        Self { project_path }
54    }
55
56    fn parse_threshold(threshold: &str) -> Severity {
57        match threshold.to_lowercase().as_str() {
58            "error" => Severity::Error,
59            "warning" => Severity::Warning,
60            "info" => Severity::Info,
61            "style" => Severity::Style,
62            _ => Severity::Warning, // Default
63        }
64    }
65
66    /// Get the category for a rule code
67    fn get_rule_category(code: &str) -> &'static str {
68        match code {
69            // Security rules
70            "DL3000" | "DL3002" | "DL3004" | "DL3047" => "security",
71            // Best practice rules
72            "DL3003" | "DL3006" | "DL3007" | "DL3008" | "DL3009" | "DL3013" |
73            "DL3014" | "DL3015" | "DL3016" | "DL3018" | "DL3019" | "DL3020" |
74            "DL3025" | "DL3027" | "DL3028" | "DL3033" | "DL3042" | "DL3059" => "best-practice",
75            // Maintainability rules
76            "DL3005" | "DL3010" | "DL3021" | "DL3022" | "DL3023" | "DL3024" |
77            "DL3026" | "DL3029" | "DL3030" | "DL3032" | "DL3034" | "DL3035" |
78            "DL3036" | "DL3044" | "DL3045" | "DL3048" | "DL3049" | "DL3050" |
79            "DL3051" | "DL3052" | "DL3053" | "DL3054" | "DL3055" | "DL3056" |
80            "DL3057" | "DL3058" | "DL3060" | "DL3061" => "maintainability",
81            // Performance rules
82            "DL3001" | "DL3011" | "DL3017" | "DL3031" | "DL3037" | "DL3038" |
83            "DL3039" | "DL3040" | "DL3041" | "DL3046" | "DL3062" => "performance",
84            // Deprecated instructions
85            "DL4000" | "DL4001" | "DL4003" | "DL4005" | "DL4006" => "deprecated",
86            // ShellCheck rules
87            _ if code.starts_with("SC") => "shell",
88            _ => "other",
89        }
90    }
91
92    /// Get priority based on severity and category
93    fn get_priority(severity: Severity, category: &str) -> &'static str {
94        match (severity, category) {
95            (Severity::Error, "security") => "critical",
96            (Severity::Error, _) => "high",
97            (Severity::Warning, "security") => "high",
98            (Severity::Warning, "best-practice") => "medium",
99            (Severity::Warning, _) => "medium",
100            (Severity::Info, _) => "low",
101            (Severity::Style, _) => "low",
102            (Severity::Ignore, _) => "info",
103        }
104    }
105
106    /// Get actionable fix recommendation for a rule
107    fn get_fix_recommendation(code: &str) -> &'static str {
108        match code {
109            "DL3000" => "Use absolute WORKDIR paths like '/app' instead of relative paths.",
110            "DL3001" => "Remove commands that have no effect in Docker (like 'ssh', 'mount').",
111            "DL3002" => "Remove the last USER instruction setting root, or add 'USER <non-root>' at the end.",
112            "DL3003" => "Use WORKDIR to change directories instead of 'cd' in RUN commands.",
113            "DL3004" => "Remove 'sudo' from RUN commands. Docker runs as root by default, or use proper USER switching.",
114            "DL3005" => "Remove 'apt-get upgrade' or 'dist-upgrade'. Pin packages instead for reproducibility.",
115            "DL3006" => "Add explicit version tag to base image, e.g., 'FROM node:18-alpine' instead of 'FROM node'.",
116            "DL3007" => "Use specific version tag instead of ':latest', e.g., 'nginx:1.25-alpine'.",
117            "DL3008" => "Pin apt package versions: 'apt-get install package=version' or use '--no-install-recommends'.",
118            "DL3009" => "Add 'rm -rf /var/lib/apt/lists/*' after apt-get install to reduce image size.",
119            "DL3010" => "Use ADD only for extracting archives. For other files, use COPY.",
120            "DL3011" => "Use valid port numbers (0-65535) in EXPOSE.",
121            "DL3013" => "Pin pip package versions: 'pip install package==version'.",
122            "DL3014" => "Add '-y' flag to apt-get install for non-interactive mode.",
123            "DL3015" => "Add '--no-install-recommends' to apt-get install to minimize image size.",
124            "DL3016" => "Pin npm package versions: 'npm install package@version'.",
125            "DL3017" => "Remove 'apt-get upgrade'. Pin specific package versions instead.",
126            "DL3018" => "Pin apk package versions: 'apk add package=version'.",
127            "DL3019" => "Add '--no-cache' to apk add instead of separate cache cleanup.",
128            "DL3020" => "Use COPY instead of ADD for files from build context. ADD is for URLs and archives.",
129            "DL3021" => "Use COPY with --from for multi-stage builds instead of COPY from external images.",
130            "DL3022" => "Use COPY --from=stage instead of --from=image for multi-stage builds.",
131            "DL3023" => "Reference build stage by name instead of number in COPY --from.",
132            "DL3024" => "Use lowercase for 'as' in multi-stage builds: 'FROM image AS builder'.",
133            "DL3025" => "Use JSON array format for CMD/ENTRYPOINT: CMD [\"executable\", \"arg1\"].",
134            "DL3026" => "Use official Docker images when possible, or document why unofficial is needed.",
135            "DL3027" => "Remove 'apt' and use 'apt-get' for scripting in Dockerfiles.",
136            "DL3028" => "Pin gem versions: 'gem install package:version'.",
137            "DL3029" => "Specify --platform explicitly for multi-arch builds.",
138            "DL3030" => "Pin yum/dnf package versions: 'yum install package-version'.",
139            "DL3032" => "Replace 'yum clean all' with 'dnf clean all' for newer distros.",
140            "DL3033" => "Add 'yum clean all' after yum install to reduce image size.",
141            "DL3034" => "Add '--setopt=install_weak_deps=False' to dnf install.",
142            "DL3035" => "Add 'dnf clean all' after dnf install to reduce image size.",
143            "DL3036" => "Pin zypper package versions: 'zypper install package=version'.",
144            "DL3037" => "Add 'zypper clean' after zypper install.",
145            "DL3038" => "Add '--no-recommends' to zypper install.",
146            "DL3039" => "Add 'zypper clean' after zypper install.",
147            "DL3040" => "Add 'dnf clean all && rm -rf /var/cache/dnf' after dnf install.",
148            "DL3041" => "Add 'microdnf clean all' after microdnf install.",
149            "DL3042" => "Avoid pip cache in builds. Use '--no-cache-dir' or set PIP_NO_CACHE_DIR=1.",
150            "DL3044" => "Only use 'HEALTHCHECK' once per Dockerfile, or it won't work correctly.",
151            "DL3045" => "Use COPY instead of ADD for local files.",
152            "DL3046" => "Use 'useradd' instead of 'adduser' for better compatibility.",
153            "DL3047" => "Add 'wget --progress=dot:giga' or 'curl --progress-bar' to show progress during download.",
154            "DL3048" => "Prefer setting flag with 'SHELL' instruction instead of inline in RUN.",
155            "DL3049" => "Add a 'LABEL maintainer=\"name\"' for documentation.",
156            "DL3050" => "Add 'LABEL version=\"x.y\"' for versioning.",
157            "DL3051" => "Add 'LABEL description=\"...\"' for documentation.",
158            "DL3052" => "Prefer relative paths with LABEL for better portability.",
159            "DL3053" => "Remove unused LABEL instructions.",
160            "DL3054" => "Use recommended labels from OCI spec (org.opencontainers.image.*).",
161            "DL3055" => "Add 'LABEL org.opencontainers.image.created' with ISO 8601 date.",
162            "DL3056" => "Add 'LABEL org.opencontainers.image.description'.",
163            "DL3057" => "Add a HEALTHCHECK instruction for container health monitoring.",
164            "DL3058" => "Add 'LABEL org.opencontainers.image.title'.",
165            "DL3059" => "Combine consecutive RUN instructions with '&&' to reduce layers.",
166            "DL3060" => "Pin package versions in yarn add: 'yarn add package@version'.",
167            "DL3061" => "Use specific image digest or tag instead of implicit latest.",
168            "DL3062" => "Prefer single RUN with '&&' over multiple RUN for related commands.",
169            "DL4000" => "Replace MAINTAINER with 'LABEL maintainer=\"name <email>\"'.",
170            "DL4001" => "Use wget or curl instead of ADD for downloading from URLs.",
171            "DL4003" => "Use 'ENTRYPOINT' and 'CMD' together properly for container startup.",
172            "DL4005" => "Prefer JSON notation for SHELL: SHELL [\"/bin/bash\", \"-c\"].",
173            "DL4006" => "Add 'SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]' before RUN with pipes.",
174            _ if code.starts_with("SC") => "See ShellCheck wiki for shell scripting fix.",
175            _ => "Review the rule documentation for specific guidance.",
176        }
177    }
178
179    /// Get documentation URL for a rule
180    fn get_rule_url(code: &str) -> String {
181        if code.starts_with("DL") || code.starts_with("SC") {
182            if code.starts_with("SC") {
183                format!("https://www.shellcheck.net/wiki/{}", code)
184            } else {
185                format!("https://github.com/hadolint/hadolint/wiki/{}", code)
186            }
187        } else {
188            String::new()
189        }
190    }
191
192    /// Format result optimized for agent decision-making
193    fn format_result(result: &LintResult, filename: &str) -> String {
194        // Categorize and enrich failures
195        let enriched_failures: Vec<serde_json::Value> = result.failures.iter().map(|f| {
196            let code = f.code.as_str();
197            let category = Self::get_rule_category(code);
198            let priority = Self::get_priority(f.severity, category);
199
200            json!({
201                "code": code,
202                "severity": format!("{:?}", f.severity).to_lowercase(),
203                "priority": priority,
204                "category": category,
205                "message": f.message,
206                "line": f.line,
207                "column": f.column,
208                "fix": Self::get_fix_recommendation(code),
209                "docs": Self::get_rule_url(code),
210            })
211        }).collect();
212
213        // Group by priority for agent decision ordering
214        let critical: Vec<_> = enriched_failures.iter()
215            .filter(|f| f["priority"] == "critical")
216            .cloned().collect();
217        let high: Vec<_> = enriched_failures.iter()
218            .filter(|f| f["priority"] == "high")
219            .cloned().collect();
220        let medium: Vec<_> = enriched_failures.iter()
221            .filter(|f| f["priority"] == "medium")
222            .cloned().collect();
223        let low: Vec<_> = enriched_failures.iter()
224            .filter(|f| f["priority"] == "low")
225            .cloned().collect();
226
227        // Group by category for thematic fixes
228        let mut by_category: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new();
229        for f in &enriched_failures {
230            let cat = f["category"].as_str().unwrap_or("other");
231            by_category.entry(cat).or_default().push(f.clone());
232        }
233
234        // Build decision context
235        let decision_context = if critical.is_empty() && high.is_empty() {
236            if medium.is_empty() && low.is_empty() {
237                "Dockerfile follows best practices. No issues found."
238            } else if medium.is_empty() {
239                "Minor improvements possible. Low priority issues only."
240            } else {
241                "Good baseline. Medium priority improvements recommended."
242            }
243        } else if !critical.is_empty() {
244            "Critical issues found. Address security/error issues first before deployment."
245        } else {
246            "High priority issues found. Review and fix before production use."
247        };
248
249        // Build agent-optimized output
250        let mut output = json!({
251            "file": filename,
252            "success": !result.has_errors(),
253            "decision_context": decision_context,
254            "summary": {
255                "total": result.failures.len(),
256                "by_priority": {
257                    "critical": critical.len(),
258                    "high": high.len(),
259                    "medium": medium.len(),
260                    "low": low.len(),
261                },
262                "by_severity": {
263                    "errors": result.failures.iter().filter(|f| f.severity == Severity::Error).count(),
264                    "warnings": result.failures.iter().filter(|f| f.severity == Severity::Warning).count(),
265                    "info": result.failures.iter().filter(|f| f.severity == Severity::Info).count(),
266                },
267                "by_category": by_category.iter().map(|(k, v)| (k.to_string(), v.len())).collect::<std::collections::HashMap<_, _>>(),
268            },
269            "action_plan": {
270                "critical": critical,
271                "high": high,
272                "medium": medium,
273                "low": low,
274            },
275        });
276
277        // Add quick fixes summary for agent
278        if !enriched_failures.is_empty() {
279            let quick_fixes: Vec<String> = enriched_failures.iter()
280                .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
281                .take(5)
282                .map(|f| format!("Line {}: {} - {}",
283                    f["line"],
284                    f["code"].as_str().unwrap_or(""),
285                    f["fix"].as_str().unwrap_or("")
286                ))
287                .collect();
288
289            if !quick_fixes.is_empty() {
290                output["quick_fixes"] = json!(quick_fixes);
291            }
292        }
293
294        if !result.parse_errors.is_empty() {
295            output["parse_errors"] = json!(result.parse_errors);
296        }
297
298        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
299    }
300}
301
302impl Tool for HadolintTool {
303    const NAME: &'static str = "hadolint";
304
305    type Error = HadolintError;
306    type Args = HadolintArgs;
307    type Output = String;
308
309    async fn definition(&self, _prompt: String) -> ToolDefinition {
310        ToolDefinition {
311            name: Self::NAME.to_string(),
312            description: "Lint Dockerfiles for best practices, security issues, and common mistakes. \
313                Returns AI-optimized JSON with issues categorized by priority (critical/high/medium/low) \
314                and type (security/best-practice/maintainability/performance/deprecated). \
315                Each issue includes an actionable fix recommendation. Use this to analyze Dockerfiles \
316                before deployment or to improve existing ones. The 'decision_context' field provides \
317                a summary for quick assessment, and 'quick_fixes' lists the most important changes."
318                .to_string(),
319            parameters: json!({
320                "type": "object",
321                "properties": {
322                    "dockerfile": {
323                        "type": "string",
324                        "description": "Path to Dockerfile relative to project root (e.g., 'Dockerfile', 'docker/Dockerfile.prod')"
325                    },
326                    "content": {
327                        "type": "string",
328                        "description": "Inline Dockerfile content to lint. Use this when you want to validate generated Dockerfile content before writing."
329                    },
330                    "ignore": {
331                        "type": "array",
332                        "items": { "type": "string" },
333                        "description": "List of rule codes to ignore (e.g., ['DL3008', 'DL3013'])"
334                    },
335                    "threshold": {
336                        "type": "string",
337                        "enum": ["error", "warning", "info", "style"],
338                        "description": "Minimum severity to report. Default is 'warning'."
339                    }
340                }
341            }),
342        }
343    }
344
345    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
346        // Build configuration
347        let mut config = HadolintConfig::default();
348
349        // Apply ignored rules
350        for rule in &args.ignore {
351            config = config.ignore(rule.as_str());
352        }
353
354        // Apply threshold
355        if let Some(threshold) = &args.threshold {
356            config = config.with_threshold(Self::parse_threshold(threshold));
357        }
358
359        // Determine source, filename, and lint
360        let (result, filename) = if let Some(content) = &args.content {
361            // Lint inline content
362            (lint(content, &config), "<inline>".to_string())
363        } else if let Some(dockerfile) = &args.dockerfile {
364            // Lint file
365            let path = self.project_path.join(dockerfile);
366            (lint_file(&path, &config), dockerfile.clone())
367        } else {
368            // Default: look for Dockerfile in project root
369            let path = self.project_path.join("Dockerfile");
370            if path.exists() {
371                (lint_file(&path, &config), "Dockerfile".to_string())
372            } else {
373                return Err(HadolintError(
374                    "No Dockerfile specified and no Dockerfile found in project root".to_string(),
375                ));
376            }
377        };
378
379        // Check for parse errors
380        if !result.parse_errors.is_empty() {
381            log::warn!("Dockerfile parse errors: {:?}", result.parse_errors);
382        }
383
384        Ok(Self::format_result(&result, &filename))
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::env::temp_dir;
392    use std::fs;
393
394    /// Helper to collect all issues from action_plan
395    fn collect_all_issues(parsed: &serde_json::Value) -> Vec<serde_json::Value> {
396        let mut all = Vec::new();
397        for priority in ["critical", "high", "medium", "low"] {
398            if let Some(arr) = parsed["action_plan"][priority].as_array() {
399                all.extend(arr.clone());
400            }
401        }
402        all
403    }
404
405    #[tokio::test]
406    async fn test_hadolint_inline_content() {
407        let tool = HadolintTool::new(temp_dir());
408        let args = HadolintArgs {
409            dockerfile: None,
410            content: Some("FROM ubuntu:latest\nRUN sudo apt-get update".to_string()),
411            ignore: vec![],
412            threshold: None,
413        };
414
415        let result = tool.call(args).await.unwrap();
416        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
417
418        // Should detect DL3007 (latest tag) and DL3004 (sudo)
419        assert!(!parsed["success"].as_bool().unwrap_or(true));
420        assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 2);
421
422        // Check new fields exist
423        assert!(parsed["decision_context"].is_string());
424        assert!(parsed["action_plan"].is_object());
425
426        // Check issues have fix recommendations
427        let issues = collect_all_issues(&parsed);
428        assert!(issues.iter().all(|i| i["fix"].is_string() && !i["fix"].as_str().unwrap().is_empty()));
429    }
430
431    #[tokio::test]
432    async fn test_hadolint_ignore_rules() {
433        let tool = HadolintTool::new(temp_dir());
434        let args = HadolintArgs {
435            dockerfile: None,
436            content: Some("FROM ubuntu:latest".to_string()),
437            ignore: vec!["DL3007".to_string()],
438            threshold: None,
439        };
440
441        let result = tool.call(args).await.unwrap();
442        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
443
444        // DL3007 should be ignored
445        let all_issues = collect_all_issues(&parsed);
446        assert!(!all_issues.iter().any(|f| f["code"] == "DL3007"));
447    }
448
449    #[tokio::test]
450    async fn test_hadolint_threshold() {
451        let tool = HadolintTool::new(temp_dir());
452        let args = HadolintArgs {
453            dockerfile: None,
454            content: Some("FROM ubuntu\nMAINTAINER test".to_string()),
455            ignore: vec![],
456            threshold: Some("error".to_string()),
457        };
458
459        let result = tool.call(args).await.unwrap();
460        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
461
462        // DL4000 (MAINTAINER deprecated) is Error, DL3006 (untagged) is Warning
463        // With error threshold, only errors should show
464        let all_issues = collect_all_issues(&parsed);
465        assert!(all_issues.iter().all(|f| f["severity"] == "error"));
466    }
467
468    #[tokio::test]
469    async fn test_hadolint_file() {
470        let temp = temp_dir().join("hadolint_test");
471        fs::create_dir_all(&temp).unwrap();
472        let dockerfile = temp.join("Dockerfile");
473        fs::write(&dockerfile, "FROM node:18-alpine\nWORKDIR /app\nCOPY . .\nCMD [\"node\", \"app.js\"]").unwrap();
474
475        let tool = HadolintTool::new(temp.clone());
476        let args = HadolintArgs {
477            dockerfile: Some("Dockerfile".to_string()),
478            content: None,
479            ignore: vec![],
480            threshold: None,
481        };
482
483        let result = tool.call(args).await.unwrap();
484        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
485
486        // This is a well-formed Dockerfile, should have few/no errors
487        assert!(parsed["success"].as_bool().unwrap_or(false));
488        assert_eq!(parsed["file"], "Dockerfile");
489
490        // Cleanup
491        fs::remove_dir_all(&temp).ok();
492    }
493
494    #[tokio::test]
495    async fn test_hadolint_valid_dockerfile() {
496        let tool = HadolintTool::new(temp_dir());
497        let dockerfile = r#"
498FROM node:18-alpine AS builder
499WORKDIR /app
500COPY package*.json ./
501RUN npm ci --only=production
502COPY . .
503RUN npm run build
504
505FROM node:18-alpine
506WORKDIR /app
507COPY --from=builder /app/dist ./dist
508USER node
509EXPOSE 3000
510CMD ["node", "dist/index.js"]
511"#;
512
513        let args = HadolintArgs {
514            dockerfile: None,
515            content: Some(dockerfile.to_string()),
516            ignore: vec![],
517            threshold: None,
518        };
519
520        let result = tool.call(args).await.unwrap();
521        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
522
523        // Well-structured Dockerfile should pass (no errors)
524        assert!(parsed["success"].as_bool().unwrap_or(false));
525        // Should have decision context
526        assert!(parsed["decision_context"].is_string());
527        // Should not have critical or high priority issues
528        assert_eq!(parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(99), 0);
529        assert_eq!(parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(99), 0);
530    }
531
532    #[tokio::test]
533    async fn test_hadolint_priority_categorization() {
534        let tool = HadolintTool::new(temp_dir());
535        let args = HadolintArgs {
536            dockerfile: None,
537            content: Some("FROM ubuntu\nRUN sudo apt-get update\nMAINTAINER test".to_string()),
538            ignore: vec![],
539            threshold: None,
540        };
541
542        let result = tool.call(args).await.unwrap();
543        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
544
545        // Check priority counts are present
546        assert!(parsed["summary"]["by_priority"]["critical"].is_number());
547        assert!(parsed["summary"]["by_priority"]["high"].is_number());
548        assert!(parsed["summary"]["by_priority"]["medium"].is_number());
549
550        // Check category counts
551        assert!(parsed["summary"]["by_category"].is_object());
552
553        // DL3004 (sudo) should be high priority security
554        let all_issues = collect_all_issues(&parsed);
555        let sudo_issue = all_issues.iter().find(|i| i["code"] == "DL3004");
556        assert!(sudo_issue.is_some());
557        assert_eq!(sudo_issue.unwrap()["category"], "security");
558    }
559
560    #[tokio::test]
561    async fn test_hadolint_quick_fixes() {
562        let tool = HadolintTool::new(temp_dir());
563        let args = HadolintArgs {
564            dockerfile: None,
565            content: Some("FROM ubuntu\nRUN sudo rm -rf /".to_string()),
566            ignore: vec![],
567            threshold: None,
568        };
569
570        let result = tool.call(args).await.unwrap();
571        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
572
573        // Should have quick_fixes for high priority issues
574        if parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(0) > 0
575            || parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(0) > 0 {
576            assert!(parsed["quick_fixes"].is_array());
577        }
578    }
579}