codeoptix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. codeoptix/__init__.py +8 -0
  2. codeoptix/acp/__init__.py +33 -0
  3. codeoptix/acp/agent.py +209 -0
  4. codeoptix/acp/bridge.py +402 -0
  5. codeoptix/acp/client_adapter.py +312 -0
  6. codeoptix/acp/code_extractor.py +125 -0
  7. codeoptix/acp/orchestrator.py +349 -0
  8. codeoptix/acp/registry.py +294 -0
  9. codeoptix/adapters/__init__.py +18 -0
  10. codeoptix/adapters/base.py +50 -0
  11. codeoptix/adapters/basic.py +195 -0
  12. codeoptix/adapters/claude_code.py +218 -0
  13. codeoptix/adapters/codex.py +327 -0
  14. codeoptix/adapters/factory.py +56 -0
  15. codeoptix/adapters/gemini_cli.py +370 -0
  16. codeoptix/artifacts/__init__.py +5 -0
  17. codeoptix/artifacts/manager.py +193 -0
  18. codeoptix/behaviors/__init__.py +45 -0
  19. codeoptix/behaviors/base.py +81 -0
  20. codeoptix/behaviors/insecure_code.py +129 -0
  21. codeoptix/behaviors/plan_drift.py +192 -0
  22. codeoptix/behaviors/vacuous_tests.py +198 -0
  23. codeoptix/cli.py +1472 -0
  24. codeoptix/evaluation/__init__.py +23 -0
  25. codeoptix/evaluation/bloom_integration.py +271 -0
  26. codeoptix/evaluation/engine.py +274 -0
  27. codeoptix/evaluation/evaluators.py +308 -0
  28. codeoptix/evaluation/scenario_generator.py +222 -0
  29. codeoptix/evolution/__init__.py +7 -0
  30. codeoptix/evolution/engine.py +206 -0
  31. codeoptix/evolution/gepa_integration.py +149 -0
  32. codeoptix/evolution/proposer.py +185 -0
  33. codeoptix/linters/__init__.py +13 -0
  34. codeoptix/linters/bandit_linter.py +172 -0
  35. codeoptix/linters/base.py +105 -0
  36. codeoptix/linters/coverage_linter.py +156 -0
  37. codeoptix/linters/flake8_linter.py +156 -0
  38. codeoptix/linters/html_accessibility_linter.py +374 -0
  39. codeoptix/linters/language_detector.py +150 -0
  40. codeoptix/linters/mypy_linter.py +184 -0
  41. codeoptix/linters/pip_audit_linter.py +152 -0
  42. codeoptix/linters/pylint_linter.py +198 -0
  43. codeoptix/linters/ruff_linter.py +206 -0
  44. codeoptix/linters/runner.py +186 -0
  45. codeoptix/linters/safety_linter.py +184 -0
  46. codeoptix/reflection/__init__.py +6 -0
  47. codeoptix/reflection/engine.py +70 -0
  48. codeoptix/reflection/generator.py +209 -0
  49. codeoptix/utils/__init__.py +1 -0
  50. codeoptix/utils/config.py +91 -0
  51. codeoptix/utils/llm.py +332 -0
  52. codeoptix/utils/retry.py +133 -0
  53. codeoptix/vendor/__init__.py +2 -0
  54. codeoptix/vendor/bloom/README.md +26 -0
  55. codeoptix/vendor/bloom/__init__.py +11 -0
  56. codeoptix/vendor/bloom/globals.py +39 -0
  57. codeoptix/vendor/bloom/orchestrators/ConversationOrchestrator.py +450 -0
  58. codeoptix/vendor/bloom/orchestrators/SimEnvOrchestrator.py +839 -0
  59. codeoptix/vendor/bloom/prompts/configurable_prompts/README.md +85 -0
  60. codeoptix/vendor/bloom/prompts/configurable_prompts/default.json +18 -0
  61. codeoptix/vendor/bloom/prompts/configurable_prompts/ideation-default.json +18 -0
  62. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_animal-welfare.json +18 -0
  63. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_contextual-optimism.json +18 -0
  64. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_defend-objects.json +18 -0
  65. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_defer-to-users.json +18 -0
  66. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_emotional-bond.json +18 -0
  67. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_flattery.json +18 -0
  68. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_hardcode-test-cases.json +18 -0
  69. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_increasing-pep.json +18 -0
  70. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_research-sandbagging.json +18 -0
  71. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_self-promotion.json +18 -0
  72. codeoptix/vendor/bloom/prompts/configurable_prompts/sandbag.json +18 -0
  73. codeoptix/vendor/bloom/prompts/configurable_prompts/self-preferential-bias.json +18 -0
  74. codeoptix/vendor/bloom/prompts/configurable_prompts/static-prompts.yaml +72 -0
  75. codeoptix/vendor/bloom/prompts/configurable_prompts/web-search.json +18 -0
  76. codeoptix/vendor/bloom/prompts/step1_understanding.py +63 -0
  77. codeoptix/vendor/bloom/prompts/step2_ideation.py +254 -0
  78. codeoptix/vendor/bloom/prompts/step3_rollout.py +120 -0
  79. codeoptix/vendor/bloom/prompts/step4_judgment.py +183 -0
  80. codeoptix/vendor/bloom/schemas/behavior.schema.json +160 -0
  81. codeoptix/vendor/bloom/schemas/conversation.schema.json +51 -0
  82. codeoptix/vendor/bloom/schemas/transcript_schema.json +2225 -0
  83. codeoptix/vendor/bloom/scripts/step2_ideation.py +667 -0
  84. codeoptix/vendor/bloom/scripts/step4_judgment.py +811 -0
  85. codeoptix/vendor/bloom/transcript_utils.py +440 -0
  86. codeoptix/vendor/bloom/utils.py +700 -0
  87. codeoptix-0.1.0.dist-info/METADATA +304 -0
  88. codeoptix-0.1.0.dist-info/RECORD +91 -0
  89. codeoptix-0.1.0.dist-info/WHEEL +4 -0
  90. codeoptix-0.1.0.dist-info/entry_points.txt +2 -0
  91. codeoptix-0.1.0.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,374 @@
1
+ """Custom HTML accessibility analyzer - no external dependencies."""
2
+
3
+ from html.parser import HTMLParser
4
+ from pathlib import Path
5
+
6
+ from codeoptix.linters.base import BaseLinter, LinterIssue, LinterResult, Severity
7
+
8
+
9
+ class HTMLAccessibilityParser(HTMLParser):
10
+ """HTML parser for accessibility analysis."""
11
+
12
+ def __init__(self):
13
+ """Initialize parser."""
14
+ super().__init__()
15
+ self.issues = []
16
+ self.current_tag: str | None = None
17
+ self.current_attrs: dict[str, str] = {}
18
+ self.has_main = False
19
+ self.has_nav = False
20
+ self.images_without_alt: list[tuple[int, int]] = []
21
+ self.headings_order: list[tuple[tuple[int, int], int, int]] = []
22
+ self.last_heading_level = 0
23
+ self.html_lang: str | None = None
24
+ self.heading_tags_seen: list[tuple[str, tuple[int, int]]] = []
25
+ self.links_missing_text: list[tuple[int, int, dict[str, str]]] = []
26
+ self.buttons_missing_text: list[tuple[int, int, dict[str, str]]] = []
27
+ self.inputs_missing_label: list[tuple[int, int, dict[str, str]]] = []
28
+ self.labels_by_for: dict[str, tuple[int, int]] = {}
29
+ self.current_data_buffer: list[str] = []
30
+ self.focus_outline_suppressed = False
31
+ self.suspicious_tabindex: list[tuple[int, int, dict[str, str]]] = []
32
+ self.aria_role_issues: list[tuple[int, int, str, str]] = []
33
+
34
+ def handle_starttag(self, tag, attrs):
35
+ """Handle start tag."""
36
+ self.current_tag = tag
37
+ self.current_attrs = {k: v for (k, v) in attrs}
38
+ self.current_data_buffer = []
39
+
40
+ # Document root and language
41
+ if tag == "html":
42
+ lang = self.current_attrs.get("lang") or self.current_attrs.get("xml:lang")
43
+ if lang:
44
+ self.html_lang = lang.strip()
45
+
46
+ # Semantic HTML landmarks
47
+ if tag == "main":
48
+ self.has_main = True
49
+ elif tag == "nav":
50
+ self.has_nav = True
51
+
52
+ # Check images
53
+ if tag == "img":
54
+ alt = self.current_attrs.get("alt")
55
+ if alt is None or alt.strip() == "":
56
+ self.images_without_alt.append(self.getpos())
57
+
58
+ # Headings and order
59
+ if tag in ["h1", "h2", "h3", "h4", "h5", "h6"]:
60
+ level = int(tag[1])
61
+ self.heading_tags_seen.append((tag, self.getpos()))
62
+ if self.last_heading_level and level > self.last_heading_level + 1:
63
+ self.headings_order.append((self.getpos(), level, self.last_heading_level))
64
+ self.last_heading_level = level
65
+
66
+ # Labels
67
+ if tag == "label":
68
+ label_for = self.current_attrs.get("for")
69
+ if label_for:
70
+ self.labels_by_for[label_for] = self.getpos()
71
+
72
+ # Inputs that should have labels
73
+ if tag == "input" and self.current_attrs.get("type") not in [
74
+ "hidden",
75
+ "submit",
76
+ "button",
77
+ "image",
78
+ ]:
79
+ input_id = self.current_attrs.get("id")
80
+ if not input_id or input_id not in self.labels_by_for:
81
+ # We will also check aria-label/labelledby via attributes
82
+ self.inputs_missing_label.append(
83
+ (self.getpos()[0], self.getpos()[1], dict(self.current_attrs))
84
+ )
85
+
86
+ # Links and buttons: record for later text/aria checks
87
+ if tag == "a":
88
+ href = self.current_attrs.get("href")
89
+ if href:
90
+ # Will decide missing text after data collected
91
+ self.links_missing_text.append(
92
+ (self.getpos()[0], self.getpos()[1], dict(self.current_attrs))
93
+ )
94
+
95
+ if tag == "button":
96
+ self.buttons_missing_text.append(
97
+ (self.getpos()[0], self.getpos()[1], dict(self.current_attrs))
98
+ )
99
+
100
+ # Tabindex heuristics
101
+ tabindex = self.current_attrs.get("tabindex")
102
+ if tabindex is not None:
103
+ try:
104
+ tabindex_val = int(tabindex)
105
+ if tabindex_val > 0 or tabindex_val < -1:
106
+ self.suspicious_tabindex.append(
107
+ (self.getpos()[0], self.getpos()[1], dict(self.current_attrs))
108
+ )
109
+ except ValueError:
110
+ self.suspicious_tabindex.append(
111
+ (self.getpos()[0], self.getpos()[1], dict(self.current_attrs))
112
+ )
113
+
114
+ # Simple ARIA/role checks
115
+ role = self.current_attrs.get("role")
116
+ if role:
117
+ # Example: role="button" on non-interactive element
118
+ if role == "button" and tag not in ("button", "a", "input"):
119
+ self.aria_role_issues.append((self.getpos()[0], self.getpos()[1], tag, role))
120
+
121
+ def handle_data(self, data):
122
+ """Handle text data."""
123
+ if self.current_tag:
124
+ self.current_data_buffer.append(data)
125
+
126
+ def handle_endtag(self, tag):
127
+ """Handle end tag, used to decide on text content for links/buttons."""
128
+ # Consolidate buffered text
129
+ text_content = "".join(self.current_data_buffer or []).strip()
130
+
131
+ if tag == "a":
132
+ if self.links_missing_text:
133
+ line, col, attrs = self.links_missing_text[-1]
134
+ # Only flag as missing if no text AND no aria-label
135
+ if not text_content and not attrs.get("aria-label"):
136
+ # Keep as-is; will be converted to issues later
137
+ pass
138
+ else:
139
+ # Remove last entry if it actually had content
140
+ self.links_missing_text.pop()
141
+
142
+ if tag == "button":
143
+ if self.buttons_missing_text:
144
+ line, col, attrs = self.buttons_missing_text[-1]
145
+ if text_content or attrs.get("aria-label"):
146
+ # Button has accessible name; drop from missing list
147
+ self.buttons_missing_text.pop()
148
+
149
+ self.current_tag = None
150
+ self.current_attrs = {}
151
+ self.current_data_buffer = []
152
+
153
+
154
+ class HTMLAccessibilityLinter(BaseLinter):
155
+ """Custom HTML accessibility analyzer - no external dependencies."""
156
+
157
+ def __init__(self, config: dict | None = None):
158
+ """Initialize HTML accessibility linter."""
159
+ super().__init__(config)
160
+ self.name = "html-accessibility"
161
+
162
+ def is_available(self) -> bool:
163
+ """Always available - pure Python, no dependencies."""
164
+ return True
165
+
166
+ def run(self, path: str, files: list[str] | None = None) -> LinterResult:
167
+ """Run HTML accessibility analysis."""
168
+ import time
169
+
170
+ start_time = time.time()
171
+
172
+ path_obj = Path(path)
173
+ if not path_obj.exists():
174
+ return LinterResult(
175
+ linter=self.name,
176
+ success=False,
177
+ issues=[],
178
+ errors=[f"Path not found: {path}"],
179
+ execution_time=0.0,
180
+ )
181
+
182
+ # Find HTML files
183
+ html_files = []
184
+ if path_obj.is_file() and path_obj.suffix in [".html", ".htm"]:
185
+ html_files = [path_obj]
186
+ elif path_obj.is_dir():
187
+ if files:
188
+ html_files = [Path(f) for f in files if Path(f).suffix in [".html", ".htm"]]
189
+ else:
190
+ html_files = list(path_obj.rglob("*.html")) + list(path_obj.rglob("*.htm"))
191
+
192
+ if not html_files:
193
+ return LinterResult(
194
+ linter=self.name,
195
+ success=True,
196
+ issues=[],
197
+ errors=["No HTML files found"],
198
+ execution_time=0.0,
199
+ )
200
+
201
+ all_issues = []
202
+
203
+ for html_file in html_files:
204
+ try:
205
+ with open(html_file, encoding="utf-8") as f:
206
+ html_content = f.read()
207
+
208
+ # Parse HTML
209
+ parser = HTMLAccessibilityParser()
210
+ parser.feed(html_content)
211
+
212
+ # Generate issues
213
+ file_issues = self._generate_issues(html_file, parser)
214
+ all_issues.extend(file_issues)
215
+
216
+ except Exception as e:
217
+ all_issues.append(
218
+ LinterIssue(
219
+ linter=self.name,
220
+ severity=Severity.MEDIUM,
221
+ message=f"Failed to parse HTML: {e!s}",
222
+ file=str(html_file),
223
+ )
224
+ )
225
+
226
+ execution_time = time.time() - start_time
227
+
228
+ return LinterResult(
229
+ linter=self.name,
230
+ success=len(all_issues) == 0,
231
+ issues=all_issues,
232
+ errors=[],
233
+ execution_time=execution_time,
234
+ )
235
+
236
+ def _generate_issues(
237
+ self, html_file: Path, parser: HTMLAccessibilityParser
238
+ ) -> list[LinterIssue]:
239
+ """Generate accessibility issues from parser results."""
240
+ issues = []
241
+
242
+ # Language of page (WCAG 3.1.1)
243
+ if parser.html_lang is None:
244
+ issues.append(
245
+ LinterIssue(
246
+ linter=self.name,
247
+ severity=Severity.MEDIUM,
248
+ message="Missing lang attribute on <html> element",
249
+ file=str(html_file),
250
+ rule_id="A11Y-MISSING-LANG",
251
+ )
252
+ )
253
+
254
+ # Semantic HTML landmarks (WCAG 1.3.1 / 2.4.1)
255
+ if not parser.has_main:
256
+ issues.append(
257
+ LinterIssue(
258
+ linter=self.name,
259
+ severity=Severity.MEDIUM,
260
+ message="Missing <main> element for main content",
261
+ file=str(html_file),
262
+ rule_id="A11Y-MISSING-MAIN",
263
+ )
264
+ )
265
+
266
+ # Check images without alt text
267
+ for pos in parser.images_without_alt:
268
+ issues.append(
269
+ LinterIssue(
270
+ linter=self.name,
271
+ severity=Severity.HIGH,
272
+ message="Image missing alt text or empty alt text",
273
+ file=str(html_file),
274
+ line=pos[0],
275
+ rule_id="A11Y-MISSING-ALT",
276
+ )
277
+ )
278
+
279
+ # Check heading order (WCAG 1.3.1)
280
+ for pos, level, last_level in parser.headings_order:
281
+ issues.append(
282
+ LinterIssue(
283
+ linter=self.name,
284
+ severity=Severity.MEDIUM,
285
+ message=f"Heading level {level} skipped (previous was {last_level})",
286
+ file=str(html_file),
287
+ line=pos[0],
288
+ rule_id="A11Y-HEADING-ORDER",
289
+ )
290
+ )
291
+
292
+ # Basic heading presence (at least one h1)
293
+ has_h1 = any(tag == "h1" for tag, _pos in parser.heading_tags_seen)
294
+ if not has_h1:
295
+ issues.append(
296
+ LinterIssue(
297
+ linter=self.name,
298
+ severity=Severity.MEDIUM,
299
+ message="Missing top-level <h1> heading",
300
+ file=str(html_file),
301
+ rule_id="A11Y-MISSING-H1",
302
+ )
303
+ )
304
+
305
+ # Links without accessible name (WCAG 2.4.4 / 4.1.2)
306
+ for line, _col, attrs in parser.links_missing_text:
307
+ issues.append(
308
+ LinterIssue(
309
+ linter=self.name,
310
+ severity=Severity.MEDIUM,
311
+ message="Link has no accessible name (no text or aria-label)",
312
+ file=str(html_file),
313
+ line=line,
314
+ rule_id="A11Y-LINK-NO-NAME",
315
+ )
316
+ )
317
+
318
+ # Buttons without accessible name (WCAG 4.1.2)
319
+ for line, _col, attrs in parser.buttons_missing_text:
320
+ issues.append(
321
+ LinterIssue(
322
+ linter=self.name,
323
+ severity=Severity.MEDIUM,
324
+ message="Button has no accessible name (no text or aria-label)",
325
+ file=str(html_file),
326
+ line=line,
327
+ rule_id="A11Y-BUTTON-NO-NAME",
328
+ )
329
+ )
330
+
331
+ # Inputs without labels (WCAG 1.3.1 / 3.3.2)
332
+ for line, _col, attrs in parser.inputs_missing_label:
333
+ # If aria-label or aria-labelledby exists, treat as labeled
334
+ if attrs.get("aria-label") or attrs.get("aria-labelledby"):
335
+ continue
336
+ issues.append(
337
+ LinterIssue(
338
+ linter=self.name,
339
+ severity=Severity.MEDIUM,
340
+ message="Form control has no associated label or aria-label",
341
+ file=str(html_file),
342
+ line=line,
343
+ rule_id="A11Y-FORM-NO-LABEL",
344
+ )
345
+ )
346
+
347
+ # Tabindex misuse (WCAG 2.1.1 / 2.4.3)
348
+ for line, _col, attrs in parser.suspicious_tabindex:
349
+ tabindex_val = attrs.get("tabindex")
350
+ issues.append(
351
+ LinterIssue(
352
+ linter=self.name,
353
+ severity=Severity.LOW,
354
+ message=f"Suspicious tabindex value '{tabindex_val}' (consider 0 or -1)",
355
+ file=str(html_file),
356
+ line=line,
357
+ rule_id="A11Y-TABINDEX-SUSPICIOUS",
358
+ )
359
+ )
360
+
361
+ # Simple ARIA role issues
362
+ for line, _col, tag, role in parser.aria_role_issues:
363
+ issues.append(
364
+ LinterIssue(
365
+ linter=self.name,
366
+ severity=Severity.LOW,
367
+ message=f"Suspicious ARIA role usage: role='{role}' on <{tag}>",
368
+ file=str(html_file),
369
+ line=line,
370
+ rule_id="A11Y-ARIA-ROLE-MISMATCH",
371
+ )
372
+ )
373
+
374
+ return issues
@@ -0,0 +1,150 @@
1
+ """Language detection for automatic linter selection."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ class LanguageDetector:
7
+ """Detects programming languages from file paths."""
8
+
9
+ # Language to file extension mapping
10
+ LANGUAGE_EXTENSIONS: dict[str, set[str]] = {
11
+ "python": {".py", ".pyi", ".pyw"},
12
+ "javascript": {".js", ".jsx", ".mjs", ".cjs"},
13
+ "typescript": {".ts", ".tsx"},
14
+ "java": {".java"},
15
+ "go": {".go"},
16
+ "rust": {".rs"},
17
+ "cpp": {".cpp", ".cc", ".cxx", ".hpp", ".h", ".hxx"},
18
+ "c": {".c", ".h"},
19
+ "ruby": {".rb"},
20
+ "php": {".php"},
21
+ "swift": {".swift"},
22
+ "kotlin": {".kt", ".kts"},
23
+ "scala": {".scala"},
24
+ "dart": {".dart"},
25
+ "r": {".r", ".R"},
26
+ "shell": {".sh", ".bash", ".zsh", ".fish"},
27
+ }
28
+
29
+ # Language to linter mapping (zero new dependencies)
30
+ LANGUAGE_LINTERS: dict[str, list[str]] = {
31
+ "python": ["ruff", "bandit", "pylint", "flake8", "mypy", "safety", "pip-audit", "coverage"],
32
+ "html": ["html-accessibility"], # Custom tool, no dependency
33
+ "javascript": [], # No JS linters yet (would require Node.js)
34
+ "typescript": [], # No TS linters yet
35
+ "java": [], # No Java linters yet
36
+ "go": [], # No Go linters yet
37
+ "rust": [], # No Rust linters yet
38
+ }
39
+
40
+ @classmethod
41
+ def detect_language(cls, file_path: str) -> str | None:
42
+ """
43
+ Detect language from file path.
44
+
45
+ Args:
46
+ file_path: Path to file
47
+
48
+ Returns:
49
+ Language name or None
50
+ """
51
+ path = Path(file_path)
52
+ extension = path.suffix.lower()
53
+
54
+ for language, extensions in cls.LANGUAGE_EXTENSIONS.items():
55
+ if extension in extensions:
56
+ return language
57
+
58
+ return None
59
+
60
+ @classmethod
61
+ def detect_languages(cls, file_paths: list[str]) -> set[str]:
62
+ """
63
+ Detect languages from multiple file paths.
64
+
65
+ Args:
66
+ file_paths: List of file paths
67
+
68
+ Returns:
69
+ Set of detected languages
70
+ """
71
+ languages = set()
72
+ for file_path in file_paths:
73
+ language = cls.detect_language(file_path)
74
+ if language:
75
+ languages.add(language)
76
+ return languages
77
+
78
+ @classmethod
79
+ def get_linters_for_language(cls, language: str) -> list[str]:
80
+ """
81
+ Get recommended linters for a language.
82
+
83
+ Args:
84
+ language: Language name
85
+
86
+ Returns:
87
+ List of linter names
88
+ """
89
+ return cls.LANGUAGE_LINTERS.get(language.lower(), [])
90
+
91
+ @classmethod
92
+ def get_linters_for_files(cls, file_paths: list[str]) -> list[str]:
93
+ """
94
+ Get recommended linters for files.
95
+
96
+ Args:
97
+ file_paths: List of file paths
98
+
99
+ Returns:
100
+ List of linter names
101
+ """
102
+ languages = cls.detect_languages(file_paths)
103
+ linters = set()
104
+
105
+ for language in languages:
106
+ linters.update(cls.get_linters_for_language(language))
107
+
108
+ return list(linters)
109
+
110
+ @classmethod
111
+ def find_config_files(cls, path: str) -> dict[str, Path]:
112
+ """
113
+ Find existing linter configuration files.
114
+
115
+ Args:
116
+ path: Path to search
117
+
118
+ Returns:
119
+ Dictionary mapping linter names to config file paths
120
+ """
121
+ path_obj = Path(path)
122
+ if path_obj.is_file():
123
+ search_dir = path_obj.parent
124
+ else:
125
+ search_dir = path_obj
126
+
127
+ config_files = {}
128
+
129
+ # Common config file patterns
130
+ config_patterns = {
131
+ "ruff": ["ruff.toml", ".ruff.toml", "pyproject.toml"],
132
+ "pylint": [".pylintrc", "pylintrc", "setup.cfg", "pyproject.toml"],
133
+ "flake8": [".flake8", "setup.cfg", "tox.ini", "pyproject.toml"],
134
+ "bandit": ["bandit.yaml", ".bandit", "setup.cfg", "pyproject.toml"],
135
+ "mypy": ["mypy.ini", ".mypy.ini", "setup.cfg", "pyproject.toml"],
136
+ }
137
+
138
+ # Walk up directory tree
139
+ current = search_dir
140
+ while current != current.parent:
141
+ for linter, patterns in config_patterns.items():
142
+ if linter not in config_files:
143
+ for pattern in patterns:
144
+ config_path = current / pattern
145
+ if config_path.exists():
146
+ config_files[linter] = config_path
147
+ break
148
+ current = current.parent
149
+
150
+ return config_files
@@ -0,0 +1,184 @@
1
+ """mypy type checker linter integration."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from codeoptix.linters.base import BaseLinter, LinterIssue, LinterResult, Severity
7
+
8
+
9
+ class MypyLinter(BaseLinter):
10
+ """mypy static type checker."""
11
+
12
+ def __init__(self, config: dict | None = None):
13
+ """Initialize mypy linter."""
14
+ super().__init__(config)
15
+ self.name = "mypy"
16
+
17
+ def is_available(self) -> bool:
18
+ """Check if mypy is available."""
19
+ try:
20
+ subprocess.run(
21
+ ["mypy", "--version"],
22
+ capture_output=True,
23
+ timeout=5,
24
+ check=False,
25
+ )
26
+ return True
27
+ except (FileNotFoundError, subprocess.TimeoutExpired):
28
+ return False
29
+
30
+ def _find_config_file(self, path: str) -> Path | None:
31
+ """Find mypy configuration file."""
32
+ path_obj = Path(path)
33
+
34
+ config_files = [
35
+ "mypy.ini",
36
+ ".mypy.ini",
37
+ "setup.cfg",
38
+ "pyproject.toml",
39
+ ]
40
+
41
+ current = path_obj if path_obj.is_file() else path_obj.parent
42
+ while current != current.parent:
43
+ for config_file in config_files:
44
+ config_path = current / config_file
45
+ if config_path.exists():
46
+ # For pyproject.toml, check if it has [tool.mypy] section
47
+ if config_file == "pyproject.toml":
48
+ try:
49
+ with open(config_path) as f:
50
+ content = f.read()
51
+ if "[tool.mypy]" in content:
52
+ return config_path
53
+ except OSError:
54
+ pass
55
+ else:
56
+ return config_path
57
+ current = current.parent
58
+
59
+ return None
60
+
61
+ def run(self, path: str, files: list[str] | None = None) -> LinterResult:
62
+ """Run mypy on code."""
63
+ import time
64
+
65
+ start_time = time.time()
66
+
67
+ if not self.is_available():
68
+ return LinterResult(
69
+ linter=self.name,
70
+ success=False,
71
+ issues=[],
72
+ errors=["mypy not found in PATH. Install with: pip install mypy"],
73
+ execution_time=0.0,
74
+ )
75
+
76
+ path_obj = Path(path)
77
+ if not path_obj.exists():
78
+ return LinterResult(
79
+ linter=self.name,
80
+ success=False,
81
+ issues=[],
82
+ errors=[f"Path not found: {path}"],
83
+ execution_time=0.0,
84
+ )
85
+
86
+ # Find config file (mypy automatically uses it)
87
+ self._find_config_file(path)
88
+
89
+ # Build command
90
+ cmd = ["mypy", "--show-error-codes", "--no-error-summary"]
91
+
92
+ # Add specific files if provided
93
+ if files:
94
+ cmd.extend(files)
95
+ elif (path_obj.is_file() and path_obj.suffix == ".py") or path_obj.is_dir():
96
+ cmd.append(str(path_obj))
97
+
98
+ try:
99
+ result = subprocess.run(
100
+ cmd,
101
+ capture_output=True,
102
+ text=True,
103
+ timeout=120,
104
+ check=False,
105
+ )
106
+
107
+ execution_time = time.time() - start_time
108
+ return self.parse_output(
109
+ result.stdout,
110
+ result.stderr,
111
+ result.returncode,
112
+ execution_time,
113
+ )
114
+ except subprocess.TimeoutExpired:
115
+ return LinterResult(
116
+ linter=self.name,
117
+ success=False,
118
+ issues=[],
119
+ errors=["mypy execution timed out"],
120
+ execution_time=120.0,
121
+ )
122
+ except Exception as e:
123
+ return LinterResult(
124
+ linter=self.name,
125
+ success=False,
126
+ issues=[],
127
+ errors=[f"mypy error: {e!s}"],
128
+ execution_time=time.time() - start_time,
129
+ )
130
+
131
+ def parse_output(
132
+ self,
133
+ output: str,
134
+ stderr: str,
135
+ returncode: int,
136
+ execution_time: float,
137
+ ) -> LinterResult:
138
+ """Parse mypy output."""
139
+ import re
140
+
141
+ issues = []
142
+ errors = []
143
+
144
+ if stderr:
145
+ errors.append(stderr)
146
+
147
+ # mypy format: filename:line: error: message [error-code]
148
+ pattern = r"(.+?):(\d+):\s*(error|note|warning):\s*(.+?)(?:\s+\[(.+?)\])?$"
149
+
150
+ for line in output.split("\n"):
151
+ line = line.strip()
152
+ if not line:
153
+ continue
154
+
155
+ match = re.match(pattern, line)
156
+ if match:
157
+ file_path, line_num, level, message, error_code = match.groups()
158
+
159
+ # Map mypy levels to severity
160
+ if level == "error":
161
+ severity = Severity.HIGH
162
+ elif level == "warning":
163
+ severity = Severity.MEDIUM
164
+ else:
165
+ severity = Severity.LOW
166
+
167
+ issue = LinterIssue(
168
+ linter=self.name,
169
+ severity=severity,
170
+ message=message.strip(),
171
+ file=file_path,
172
+ line=int(line_num) if line_num.isdigit() else None,
173
+ rule_id=error_code,
174
+ )
175
+ issues.append(issue)
176
+
177
+ return LinterResult(
178
+ linter=self.name,
179
+ success=returncode == 0 or len(issues) == 0,
180
+ issues=issues,
181
+ errors=errors,
182
+ execution_time=execution_time,
183
+ raw_output=output,
184
+ )