codeoptix 0.1.3__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.
- codeoptix/__init__.py +8 -0
- codeoptix/acp/__init__.py +33 -0
- codeoptix/acp/agent.py +209 -0
- codeoptix/acp/bridge.py +402 -0
- codeoptix/acp/client_adapter.py +312 -0
- codeoptix/acp/code_extractor.py +125 -0
- codeoptix/acp/orchestrator.py +349 -0
- codeoptix/acp/registry.py +294 -0
- codeoptix/adapters/__init__.py +18 -0
- codeoptix/adapters/base.py +50 -0
- codeoptix/adapters/basic.py +195 -0
- codeoptix/adapters/claude_code.py +221 -0
- codeoptix/adapters/codex.py +327 -0
- codeoptix/adapters/factory.py +56 -0
- codeoptix/adapters/gemini_cli.py +370 -0
- codeoptix/artifacts/__init__.py +5 -0
- codeoptix/artifacts/manager.py +193 -0
- codeoptix/behaviors/__init__.py +45 -0
- codeoptix/behaviors/base.py +81 -0
- codeoptix/behaviors/insecure_code.py +129 -0
- codeoptix/behaviors/plan_drift.py +192 -0
- codeoptix/behaviors/vacuous_tests.py +198 -0
- codeoptix/cli.py +1468 -0
- codeoptix/evaluation/__init__.py +23 -0
- codeoptix/evaluation/bloom_integration.py +271 -0
- codeoptix/evaluation/engine.py +274 -0
- codeoptix/evaluation/evaluators.py +308 -0
- codeoptix/evaluation/scenario_generator.py +222 -0
- codeoptix/evolution/__init__.py +7 -0
- codeoptix/evolution/engine.py +206 -0
- codeoptix/evolution/gepa_integration.py +149 -0
- codeoptix/evolution/proposer.py +185 -0
- codeoptix/linters/__init__.py +13 -0
- codeoptix/linters/bandit_linter.py +172 -0
- codeoptix/linters/base.py +105 -0
- codeoptix/linters/coverage_linter.py +156 -0
- codeoptix/linters/flake8_linter.py +156 -0
- codeoptix/linters/html_accessibility_linter.py +374 -0
- codeoptix/linters/language_detector.py +150 -0
- codeoptix/linters/mypy_linter.py +184 -0
- codeoptix/linters/pip_audit_linter.py +152 -0
- codeoptix/linters/pylint_linter.py +198 -0
- codeoptix/linters/ruff_linter.py +206 -0
- codeoptix/linters/runner.py +186 -0
- codeoptix/linters/safety_linter.py +184 -0
- codeoptix/reflection/__init__.py +6 -0
- codeoptix/reflection/engine.py +70 -0
- codeoptix/reflection/generator.py +209 -0
- codeoptix/utils/__init__.py +1 -0
- codeoptix/utils/config.py +91 -0
- codeoptix/utils/llm.py +334 -0
- codeoptix/utils/retry.py +133 -0
- codeoptix/vendor/__init__.py +2 -0
- codeoptix/vendor/bloom/README.md +26 -0
- codeoptix/vendor/bloom/__init__.py +11 -0
- codeoptix/vendor/bloom/globals.py +39 -0
- codeoptix/vendor/bloom/orchestrators/ConversationOrchestrator.py +450 -0
- codeoptix/vendor/bloom/orchestrators/SimEnvOrchestrator.py +839 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/README.md +85 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/default.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/ideation-default.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_animal-welfare.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_contextual-optimism.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_defend-objects.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_defer-to-users.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_emotional-bond.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_flattery.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_hardcode-test-cases.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_increasing-pep.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_research-sandbagging.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/mo_self-promotion.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/sandbag.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/self-preferential-bias.json +18 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/static-prompts.yaml +72 -0
- codeoptix/vendor/bloom/prompts/configurable_prompts/web-search.json +18 -0
- codeoptix/vendor/bloom/prompts/step1_understanding.py +63 -0
- codeoptix/vendor/bloom/prompts/step2_ideation.py +254 -0
- codeoptix/vendor/bloom/prompts/step3_rollout.py +120 -0
- codeoptix/vendor/bloom/prompts/step4_judgment.py +183 -0
- codeoptix/vendor/bloom/schemas/behavior.schema.json +160 -0
- codeoptix/vendor/bloom/schemas/conversation.schema.json +51 -0
- codeoptix/vendor/bloom/schemas/transcript_schema.json +2225 -0
- codeoptix/vendor/bloom/scripts/step2_ideation.py +667 -0
- codeoptix/vendor/bloom/scripts/step4_judgment.py +811 -0
- codeoptix/vendor/bloom/transcript_utils.py +440 -0
- codeoptix/vendor/bloom/utils.py +700 -0
- codeoptix-0.1.3.dist-info/METADATA +295 -0
- codeoptix-0.1.3.dist-info/RECORD +92 -0
- codeoptix-0.1.3.dist-info/WHEEL +5 -0
- codeoptix-0.1.3.dist-info/entry_points.txt +2 -0
- codeoptix-0.1.3.dist-info/licenses/LICENSE +203 -0
- codeoptix-0.1.3.dist-info/top_level.txt +1 -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
|
+
)
|