@lugom.io/hefesto 0.2.0 → 0.3.0
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.
- package/agents/hefesto-argos.md +279 -0
- package/agents/hefesto-athena.md +379 -0
- package/agents/hefesto-hermes.md +128 -0
- package/bin/install.js +54 -20
- package/package.json +3 -3
- package/skills/hefesto-context/SKILL.md +28 -8
- package/skills/hefesto-design/SKILL.md +194 -0
- package/skills/hefesto-design/data/animations.csv +21 -0
- package/skills/hefesto-design/data/anti-patterns.csv +41 -0
- package/skills/hefesto-design/data/charts.csv +26 -0
- package/skills/hefesto-design/data/colors.csv +108 -0
- package/skills/hefesto-design/data/components.csv +31 -0
- package/skills/hefesto-design/data/google-fonts.csv +56 -0
- package/skills/hefesto-design/data/icons.csv +23 -0
- package/skills/hefesto-design/data/landing-pages.csv +28 -0
- package/skills/hefesto-design/data/products.csv +46 -0
- package/skills/hefesto-design/data/spacing.csv +16 -0
- package/skills/hefesto-design/data/styles.csv +53 -0
- package/skills/hefesto-design/data/typography.csv +41 -0
- package/skills/hefesto-design/data/ux-rules.csv +61 -0
- package/skills/hefesto-design/references/accessibility.md +335 -0
- package/skills/hefesto-design/references/aesthetics.md +343 -0
- package/skills/hefesto-design/references/anti-patterns.md +107 -0
- package/skills/hefesto-design/references/checklist.md +66 -0
- package/skills/hefesto-design/references/color-psychology.md +203 -0
- package/skills/hefesto-design/references/component-specs.md +318 -0
- package/skills/hefesto-design/references/polish.md +339 -0
- package/skills/hefesto-design/references/token-architecture.md +394 -0
- package/skills/hefesto-design/references/ux-rules.md +349 -0
- package/skills/hefesto-design/scripts/__pycache__/audit.cpython-314.pyc +0 -0
- package/skills/hefesto-design/scripts/__pycache__/contrast.cpython-314.pyc +0 -0
- package/skills/hefesto-design/scripts/__pycache__/core.cpython-314.pyc +0 -0
- package/skills/hefesto-design/scripts/__pycache__/design_system.cpython-314.pyc +0 -0
- package/skills/hefesto-design/scripts/__pycache__/search.cpython-314.pyc +0 -0
- package/skills/hefesto-design/scripts/__pycache__/validate_tokens.cpython-314.pyc +0 -0
- package/skills/hefesto-design/scripts/audit.py +450 -0
- package/skills/hefesto-design/scripts/contrast.py +195 -0
- package/skills/hefesto-design/scripts/core.py +155 -0
- package/skills/hefesto-design/scripts/design_system.py +311 -0
- package/skills/hefesto-design/scripts/search.py +235 -0
- package/skills/hefesto-design/scripts/validate_tokens.py +274 -0
- package/{commands/hefesto/init.md → skills/hefesto-init/SKILL.md} +5 -2
- package/{commands/hefesto/new-feature.md → skills/hefesto-new-feature/SKILL.md} +5 -2
- package/{commands/hefesto/update.md → skills/hefesto-update/SKILL.md} +6 -3
- package/templates/DESIGN.md +137 -0
- package/templates/RECON.md +54 -0
- package/templates/RESEARCH.md +22 -25
- package/templates/STATE.md +1 -1
- package/templates/VERDICT.md +52 -0
- package/agents/.gitkeep +0 -0
- package/agents/hefesto-researcher.md +0 -180
- package/commands/hefesto/status.md +0 -46
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scanner de violações do contrato de design para o Hefesto.
|
|
3
|
+
|
|
4
|
+
Varre código-fonte contra o DESIGN.md e reporta cores não autorizadas,
|
|
5
|
+
fontes não contratadas, espaçamentos fora da escala e CTAs genéricos.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SCANNABLE_EXTENSIONS = {
|
|
16
|
+
".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss",
|
|
17
|
+
".html", ".astro", ".ts", ".js",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
GENERIC_CTAS = {
|
|
21
|
+
"submit", "click here", "clique aqui", "ok", "cancel", "cancelar",
|
|
22
|
+
"send", "enviar", "close", "fechar", "yes", "sim", "no", "não",
|
|
23
|
+
"next", "próximo", "proximo", "previous", "anterior", "back", "voltar",
|
|
24
|
+
"continue", "continuar", "accept", "aceitar", "confirm", "confirmar",
|
|
25
|
+
"delete", "deletar", "remove", "remover", "save", "salvar",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_binary(filepath):
|
|
30
|
+
"""Verifica se o arquivo parece ser binário."""
|
|
31
|
+
try:
|
|
32
|
+
with open(filepath, "rb") as f:
|
|
33
|
+
chunk = f.read(1024)
|
|
34
|
+
return b"\x00" in chunk
|
|
35
|
+
except (IOError, OSError):
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_design_md(design_path):
|
|
40
|
+
"""Extrai contratos do DESIGN.md.
|
|
41
|
+
|
|
42
|
+
Retorna dict com:
|
|
43
|
+
colors: set de cores hex normalizadas (lowercase)
|
|
44
|
+
fonts: set de nomes de fonte (lowercase)
|
|
45
|
+
spacing_base: int (unidade base de espaçamento, padrão 4)
|
|
46
|
+
cta_texts: set de textos de CTA aprovados (lowercase)
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
with open(design_path, "r", encoding="utf-8") as f:
|
|
50
|
+
content = f.read()
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
print(f"Erro: arquivo não encontrado: {design_path}", file=sys.stderr)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
contract = {
|
|
56
|
+
"colors": set(),
|
|
57
|
+
"fonts": set(),
|
|
58
|
+
"spacing_base": 4,
|
|
59
|
+
"cta_texts": set(),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
sections = _split_sections(content)
|
|
63
|
+
|
|
64
|
+
# Cores
|
|
65
|
+
colors_text = sections.get("contrato de cores", "")
|
|
66
|
+
for match in re.finditer(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b", colors_text):
|
|
67
|
+
contract["colors"].add(match.group(0).lower())
|
|
68
|
+
|
|
69
|
+
# Tipografia
|
|
70
|
+
typo_text = sections.get("contrato de tipografia", "")
|
|
71
|
+
for match in re.finditer(r"font-family[:\s]*[\"']?([^\"';,\n]+)", typo_text, re.IGNORECASE):
|
|
72
|
+
contract["fonts"].add(match.group(1).strip().lower())
|
|
73
|
+
# Também captura nomes entre aspas ou backticks
|
|
74
|
+
for match in re.finditer(r"[`\"']([A-Z][a-zA-Z\s]+(?:Sans|Serif|Mono|Display|Text)?)[`\"']", typo_text):
|
|
75
|
+
contract["fonts"].add(match.group(1).strip().lower())
|
|
76
|
+
|
|
77
|
+
# Espaçamento
|
|
78
|
+
spacing_text = sections.get("contrato de espacamento", sections.get("contrato de espaçamento", ""))
|
|
79
|
+
base_match = re.search(r"base[:\s]*(\d+)", spacing_text, re.IGNORECASE)
|
|
80
|
+
if base_match:
|
|
81
|
+
contract["spacing_base"] = int(base_match.group(1))
|
|
82
|
+
|
|
83
|
+
# Copywriting
|
|
84
|
+
copy_text = sections.get("copywriting", "")
|
|
85
|
+
for match in re.finditer(r"[`\"']([^`\"']+)[`\"']", copy_text):
|
|
86
|
+
contract["cta_texts"].add(match.group(1).strip().lower())
|
|
87
|
+
|
|
88
|
+
return contract
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _split_sections(content):
|
|
92
|
+
"""Divide conteúdo markdown em seções por heading."""
|
|
93
|
+
sections = {}
|
|
94
|
+
current_key = ""
|
|
95
|
+
current_lines = []
|
|
96
|
+
|
|
97
|
+
for line in content.split("\n"):
|
|
98
|
+
heading_match = re.match(r"^#{1,4}\s+(.+)", line)
|
|
99
|
+
if heading_match:
|
|
100
|
+
if current_key:
|
|
101
|
+
sections[current_key] = "\n".join(current_lines)
|
|
102
|
+
raw_key = heading_match.group(1).strip().lower()
|
|
103
|
+
# Normaliza removendo acentos simples para matching
|
|
104
|
+
current_key = raw_key.replace("ã", "a").replace("é", "e").replace("ç", "c").replace("á", "a").replace("ó", "o").replace("ú", "u")
|
|
105
|
+
current_lines = []
|
|
106
|
+
else:
|
|
107
|
+
current_lines.append(line)
|
|
108
|
+
|
|
109
|
+
if current_key:
|
|
110
|
+
sections[current_key] = "\n".join(current_lines)
|
|
111
|
+
|
|
112
|
+
return sections
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def scan_colors(filepath, lines, contract_colors):
|
|
116
|
+
"""Encontra cores hex não contratadas."""
|
|
117
|
+
violations = []
|
|
118
|
+
hex_pattern = re.compile(r"#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b")
|
|
119
|
+
|
|
120
|
+
var_pattern = re.compile(r"var\([^)]*\)")
|
|
121
|
+
|
|
122
|
+
for lineno, line in enumerate(lines, 1):
|
|
123
|
+
# Build set of positions covered by var() expressions to skip
|
|
124
|
+
var_spans = set()
|
|
125
|
+
for vm in var_pattern.finditer(line):
|
|
126
|
+
for pos in range(vm.start(), vm.end()):
|
|
127
|
+
var_spans.add(pos)
|
|
128
|
+
|
|
129
|
+
for match in hex_pattern.finditer(line):
|
|
130
|
+
# Skip hex colors inside var() — e.g., var(--color, #abc123)
|
|
131
|
+
if match.start() in var_spans:
|
|
132
|
+
continue
|
|
133
|
+
found = match.group(0).lower()
|
|
134
|
+
# Normaliza #RGB para #RRGGBB para comparação
|
|
135
|
+
if len(found) == 4:
|
|
136
|
+
expanded = "#" + "".join(c * 2 for c in found[1:])
|
|
137
|
+
if expanded not in contract_colors and found not in contract_colors:
|
|
138
|
+
violations.append({
|
|
139
|
+
"file": filepath,
|
|
140
|
+
"line": lineno,
|
|
141
|
+
"type": "color",
|
|
142
|
+
"found": found,
|
|
143
|
+
"suggestion": "Use uma cor do contrato de cores do DESIGN.md",
|
|
144
|
+
})
|
|
145
|
+
elif found not in contract_colors:
|
|
146
|
+
violations.append({
|
|
147
|
+
"file": filepath,
|
|
148
|
+
"line": lineno,
|
|
149
|
+
"type": "color",
|
|
150
|
+
"found": found,
|
|
151
|
+
"suggestion": "Use uma cor do contrato de cores do DESIGN.md",
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
return violations
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def scan_typography(filepath, lines, contract_fonts):
|
|
158
|
+
"""Encontra font-family não contratadas."""
|
|
159
|
+
violations = []
|
|
160
|
+
font_pattern = re.compile(r"font-family\s*:\s*([^;}\n]+)", re.IGNORECASE)
|
|
161
|
+
font_js_pattern = re.compile(r"fontFamily\s*:\s*['\"]([^'\"]+)['\"]")
|
|
162
|
+
|
|
163
|
+
for lineno, line in enumerate(lines, 1):
|
|
164
|
+
# CSS-in-JS: fontFamily: "Inter" / fontFamily: 'Inter'
|
|
165
|
+
for match in font_js_pattern.finditer(line):
|
|
166
|
+
font_name = match.group(1).strip()
|
|
167
|
+
normalized = font_name.lower()
|
|
168
|
+
if normalized not in {"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "inherit", "initial", "unset"}:
|
|
169
|
+
if normalized not in contract_fonts:
|
|
170
|
+
violations.append({
|
|
171
|
+
"file": filepath,
|
|
172
|
+
"line": lineno,
|
|
173
|
+
"type": "typography",
|
|
174
|
+
"found": font_name,
|
|
175
|
+
"suggestion": f"Use uma fonte do contrato: {', '.join(contract_fonts) or '(nenhuma definida)'}",
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
for match in font_pattern.finditer(line):
|
|
179
|
+
fonts_str = match.group(1)
|
|
180
|
+
# Extrai nomes de fontes individuais
|
|
181
|
+
font_names = re.findall(r"[\"']?([a-zA-Z][a-zA-Z\s-]+)[\"']?", fonts_str)
|
|
182
|
+
for font_name in font_names:
|
|
183
|
+
normalized = font_name.strip().lower()
|
|
184
|
+
# Ignora fontes genéricas CSS
|
|
185
|
+
if normalized in {"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "inherit", "initial", "unset"}:
|
|
186
|
+
continue
|
|
187
|
+
if normalized not in contract_fonts:
|
|
188
|
+
violations.append({
|
|
189
|
+
"file": filepath,
|
|
190
|
+
"line": lineno,
|
|
191
|
+
"type": "typography",
|
|
192
|
+
"found": font_name.strip(),
|
|
193
|
+
"suggestion": f"Use uma fonte do contrato: {', '.join(contract_fonts) or '(nenhuma definida)'}",
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
return violations
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def scan_spacing(filepath, lines, base_unit):
|
|
200
|
+
"""Encontra valores de espaçamento que não são múltiplos da unidade base."""
|
|
201
|
+
violations = []
|
|
202
|
+
# CSS inline: padding: 13px, margin: 7px 5px, gap: 9px
|
|
203
|
+
spacing_pattern = re.compile(
|
|
204
|
+
r"(?:padding|margin|gap|top|right|bottom|left)\s*:\s*(\d+)\s*px",
|
|
205
|
+
re.IGNORECASE,
|
|
206
|
+
)
|
|
207
|
+
# CSS shorthand: padding: 10px 15px (captures each value)
|
|
208
|
+
spacing_shorthand = re.compile(
|
|
209
|
+
r"(?:padding|margin)\s*:\s*((?:\d+px\s*)+)",
|
|
210
|
+
re.IGNORECASE,
|
|
211
|
+
)
|
|
212
|
+
# CSS-in-JS: padding: 13, margin: 7 (numeric values without px)
|
|
213
|
+
spacing_js_pattern = re.compile(
|
|
214
|
+
r"(?:padding|margin|gap|top|right|bottom|left)\s*:\s*(\d+)\s*[,}\n]",
|
|
215
|
+
)
|
|
216
|
+
# Tailwind: p-3, m-5, gap-7 (scale * 4 = px)
|
|
217
|
+
tailwind_pattern = re.compile(r"\b(?:p|m|gap)-(\d+)\b")
|
|
218
|
+
|
|
219
|
+
for lineno, line in enumerate(lines, 1):
|
|
220
|
+
checked_values = set()
|
|
221
|
+
|
|
222
|
+
for match in spacing_pattern.finditer(line):
|
|
223
|
+
value = int(match.group(1))
|
|
224
|
+
if value > 0 and value % base_unit != 0 and value not in checked_values:
|
|
225
|
+
checked_values.add(value)
|
|
226
|
+
violations.append({
|
|
227
|
+
"file": filepath,
|
|
228
|
+
"line": lineno,
|
|
229
|
+
"type": "spacing",
|
|
230
|
+
"found": f"{value}px",
|
|
231
|
+
"suggestion": f"Use múltiplo de {base_unit}px (ex: {(value // base_unit) * base_unit}px ou {((value // base_unit) + 1) * base_unit}px)",
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
# CSS shorthand: extract each px value
|
|
235
|
+
for match in spacing_shorthand.finditer(line):
|
|
236
|
+
for val_match in re.finditer(r"(\d+)px", match.group(1)):
|
|
237
|
+
value = int(val_match.group(1))
|
|
238
|
+
if value > 0 and value % base_unit != 0 and value not in checked_values:
|
|
239
|
+
checked_values.add(value)
|
|
240
|
+
violations.append({
|
|
241
|
+
"file": filepath,
|
|
242
|
+
"line": lineno,
|
|
243
|
+
"type": "spacing",
|
|
244
|
+
"found": f"{value}px",
|
|
245
|
+
"suggestion": f"Use múltiplo de {base_unit}px (ex: {(value // base_unit) * base_unit}px ou {((value // base_unit) + 1) * base_unit}px)",
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
# CSS-in-JS numeric values
|
|
249
|
+
for match in spacing_js_pattern.finditer(line):
|
|
250
|
+
value = int(match.group(1))
|
|
251
|
+
if value > 0 and value % base_unit != 0 and value not in checked_values:
|
|
252
|
+
checked_values.add(value)
|
|
253
|
+
violations.append({
|
|
254
|
+
"file": filepath,
|
|
255
|
+
"line": lineno,
|
|
256
|
+
"type": "spacing",
|
|
257
|
+
"found": f"{value} (CSS-in-JS)",
|
|
258
|
+
"suggestion": f"Use múltiplo de {base_unit} (ex: {(value // base_unit) * base_unit} ou {((value // base_unit) + 1) * base_unit})",
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
# Tailwind: scale value * 4 = px
|
|
262
|
+
for match in tailwind_pattern.finditer(line):
|
|
263
|
+
scale = int(match.group(1))
|
|
264
|
+
px_value = scale * 4
|
|
265
|
+
if px_value > 0 and px_value % base_unit != 0:
|
|
266
|
+
violations.append({
|
|
267
|
+
"file": filepath,
|
|
268
|
+
"line": lineno,
|
|
269
|
+
"type": "spacing",
|
|
270
|
+
"found": f"{match.group(0)} ({px_value}px)",
|
|
271
|
+
"suggestion": f"Use múltiplo de {base_unit}px (ex: {(px_value // base_unit) * base_unit}px ou {((px_value // base_unit) + 1) * base_unit}px)",
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
return violations
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def scan_copywriting(filepath, lines):
|
|
278
|
+
"""Encontra CTAs genéricos."""
|
|
279
|
+
violations = []
|
|
280
|
+
# Build pattern for matching generic CTAs in JS strings
|
|
281
|
+
ctas_pattern = "|".join(re.escape(cta) for cta in GENERIC_CTAS)
|
|
282
|
+
# React props: label="Submit", label='Submit'
|
|
283
|
+
react_prop_pattern = re.compile(
|
|
284
|
+
r"""(?:label|buttonText|title|placeholder|aria-label)\s*=\s*['"](%s)['"]""" % ctas_pattern,
|
|
285
|
+
re.IGNORECASE,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
for lineno, line in enumerate(lines, 1):
|
|
289
|
+
# Busca em conteúdo de texto entre tags ou em atributos
|
|
290
|
+
text_matches = re.findall(r">([^<]+)<", line)
|
|
291
|
+
attr_matches = re.findall(r"(?:label|title|placeholder|aria-label)\s*=\s*[\"']([^\"']+)[\"']", line, re.IGNORECASE)
|
|
292
|
+
|
|
293
|
+
for text in text_matches + attr_matches:
|
|
294
|
+
text_lower = text.strip().lower()
|
|
295
|
+
if text_lower in GENERIC_CTAS:
|
|
296
|
+
violations.append({
|
|
297
|
+
"file": filepath,
|
|
298
|
+
"line": lineno,
|
|
299
|
+
"type": "copywriting",
|
|
300
|
+
"found": text.strip(),
|
|
301
|
+
"suggestion": "Use texto de ação específico (ex: 'Criar conta' em vez de 'Submit')",
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
# JSX text: >Submit< (no spaces around, single word)
|
|
305
|
+
for match in re.finditer(r">\s*(%s)\s*<" % ctas_pattern, line, re.IGNORECASE):
|
|
306
|
+
found = match.group(1).strip()
|
|
307
|
+
# Avoid duplicate if already caught by text_matches above
|
|
308
|
+
if found.lower() not in {t.strip().lower() for t in text_matches}:
|
|
309
|
+
violations.append({
|
|
310
|
+
"file": filepath,
|
|
311
|
+
"line": lineno,
|
|
312
|
+
"type": "copywriting",
|
|
313
|
+
"found": found,
|
|
314
|
+
"suggestion": "Use texto de ação específico (ex: 'Criar conta' em vez de 'Submit')",
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
# React props: label="Submit"
|
|
318
|
+
for match in react_prop_pattern.finditer(line):
|
|
319
|
+
found = match.group(1).strip()
|
|
320
|
+
# Avoid duplicate if already caught by attr_matches above
|
|
321
|
+
if found.lower() not in {t.strip().lower() for t in attr_matches}:
|
|
322
|
+
violations.append({
|
|
323
|
+
"file": filepath,
|
|
324
|
+
"line": lineno,
|
|
325
|
+
"type": "copywriting",
|
|
326
|
+
"found": found,
|
|
327
|
+
"suggestion": "Use texto de ação específico (ex: 'Criar conta' em vez de 'Submit')",
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
return violations
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def walk_source(src_dir):
|
|
334
|
+
"""Percorre diretório fonte e retorna arquivos escaneáveis."""
|
|
335
|
+
files = []
|
|
336
|
+
for root, _dirs, filenames in os.walk(src_dir):
|
|
337
|
+
# Ignora node_modules, .git, dist, build
|
|
338
|
+
parts = root.split(os.sep)
|
|
339
|
+
if any(p in {"node_modules", ".git", "dist", "build", ".next", "__pycache__"} for p in parts):
|
|
340
|
+
continue
|
|
341
|
+
for fname in filenames:
|
|
342
|
+
ext = os.path.splitext(fname)[1].lower()
|
|
343
|
+
if ext in SCANNABLE_EXTENSIONS:
|
|
344
|
+
files.append(os.path.join(root, fname))
|
|
345
|
+
return files
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def run_audit(design_path, src_dir, checks=None):
|
|
349
|
+
"""Executa auditoria completa."""
|
|
350
|
+
contract = parse_design_md(design_path)
|
|
351
|
+
all_checks = {"colors", "typography", "spacing", "copywriting"}
|
|
352
|
+
active_checks = {checks} if checks else all_checks
|
|
353
|
+
|
|
354
|
+
files = walk_source(src_dir)
|
|
355
|
+
if not files:
|
|
356
|
+
print(f"Aviso: nenhum arquivo escaneável encontrado em {src_dir}", file=sys.stderr)
|
|
357
|
+
return []
|
|
358
|
+
|
|
359
|
+
all_violations = []
|
|
360
|
+
for filepath in files:
|
|
361
|
+
if _is_binary(filepath):
|
|
362
|
+
continue
|
|
363
|
+
try:
|
|
364
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
365
|
+
lines = f.readlines()
|
|
366
|
+
except (IOError, OSError):
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
if "colors" in active_checks and contract["colors"]:
|
|
370
|
+
all_violations.extend(scan_colors(filepath, lines, contract["colors"]))
|
|
371
|
+
if "typography" in active_checks and contract["fonts"]:
|
|
372
|
+
all_violations.extend(scan_typography(filepath, lines, contract["fonts"]))
|
|
373
|
+
if "spacing" in active_checks:
|
|
374
|
+
all_violations.extend(scan_spacing(filepath, lines, contract["spacing_base"]))
|
|
375
|
+
if "copywriting" in active_checks:
|
|
376
|
+
all_violations.extend(scan_copywriting(filepath, lines))
|
|
377
|
+
|
|
378
|
+
return all_violations
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def format_violations(violations, fmt="table"):
|
|
382
|
+
"""Formata e imprime violações."""
|
|
383
|
+
if not violations:
|
|
384
|
+
print("\n Nenhuma violação encontrada. Contrato de design respeitado!\n")
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
if fmt == "json":
|
|
388
|
+
print(json.dumps(violations, indent=2, ensure_ascii=False))
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
print(f"\n {len(violations)} violação(ões) encontrada(s):\n")
|
|
392
|
+
|
|
393
|
+
# Agrupa por tipo
|
|
394
|
+
by_type = {}
|
|
395
|
+
for v in violations:
|
|
396
|
+
by_type.setdefault(v["type"], []).append(v)
|
|
397
|
+
|
|
398
|
+
for vtype, items in by_type.items():
|
|
399
|
+
print(f" [{vtype.upper()}] ({len(items)} violações)")
|
|
400
|
+
for item in items:
|
|
401
|
+
print(f" {item['file']}:{item['line']}")
|
|
402
|
+
print(f" Encontrado: {item['found']}")
|
|
403
|
+
print(f" Sugestão: {item['suggestion']}")
|
|
404
|
+
print()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main():
|
|
408
|
+
parser = argparse.ArgumentParser(
|
|
409
|
+
description="Scanner de violações do contrato de design.",
|
|
410
|
+
epilog="""Exemplos:
|
|
411
|
+
python3 audit.py --design .hefesto/DESIGN.md --src src/
|
|
412
|
+
python3 audit.py --design .hefesto/DESIGN.md --src src/ --check colors
|
|
413
|
+
python3 audit.py --design .hefesto/DESIGN.md --src src/ -f json
|
|
414
|
+
""",
|
|
415
|
+
)
|
|
416
|
+
parser.add_argument("--design", required=True, help="Caminho para DESIGN.md")
|
|
417
|
+
parser.add_argument("--src", required=True, help="Diretório fonte para escanear")
|
|
418
|
+
parser.add_argument(
|
|
419
|
+
"--check",
|
|
420
|
+
choices=["colors", "typography", "spacing", "copywriting"],
|
|
421
|
+
default=None,
|
|
422
|
+
help="Verificar apenas um tipo específico",
|
|
423
|
+
)
|
|
424
|
+
parser.add_argument(
|
|
425
|
+
"-f", "--format",
|
|
426
|
+
choices=["table", "json"],
|
|
427
|
+
default="table",
|
|
428
|
+
help="Formato de saída (padrão: table)",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
args = parser.parse_args()
|
|
432
|
+
|
|
433
|
+
if not os.path.isfile(args.design):
|
|
434
|
+
print(f"Erro: DESIGN.md não encontrado: {args.design}", file=sys.stderr)
|
|
435
|
+
sys.exit(1)
|
|
436
|
+
|
|
437
|
+
if not os.path.isdir(args.src):
|
|
438
|
+
print(f"Erro: diretório fonte não encontrado: {args.src}", file=sys.stderr)
|
|
439
|
+
sys.exit(1)
|
|
440
|
+
|
|
441
|
+
violations = run_audit(args.design, args.src, args.check)
|
|
442
|
+
format_violations(violations, args.format)
|
|
443
|
+
|
|
444
|
+
# Exit code não-zero se há violações
|
|
445
|
+
if violations:
|
|
446
|
+
sys.exit(1)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
main()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verificador de contraste WCAG 2.1 para o Hefesto.
|
|
3
|
+
|
|
4
|
+
Calcula ratio de contraste entre cores e valida conformidade com WCAG AA/AAA.
|
|
5
|
+
Pode verificar pares individuais ou extrair cores de um DESIGN.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_hex(color):
|
|
14
|
+
"""Converte cor hexadecimal para (R, G, B) em 0-255.
|
|
15
|
+
|
|
16
|
+
Aceita formatos: #RGB, #RRGGBB, #RRGGBBAA (ignora alpha).
|
|
17
|
+
"""
|
|
18
|
+
color = color.strip().lstrip("#")
|
|
19
|
+
if len(color) == 3:
|
|
20
|
+
color = "".join(c * 2 for c in color)
|
|
21
|
+
elif len(color) == 8:
|
|
22
|
+
color = color[:6] # ignora alpha
|
|
23
|
+
elif len(color) != 6:
|
|
24
|
+
raise ValueError(f"Cor hexadecimal inválida: #{color}")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
r = int(color[0:2], 16)
|
|
28
|
+
g = int(color[2:4], 16)
|
|
29
|
+
b = int(color[4:6], 16)
|
|
30
|
+
except ValueError:
|
|
31
|
+
raise ValueError(f"Cor hexadecimal inválida: #{color}")
|
|
32
|
+
|
|
33
|
+
return r, g, b
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def relative_luminance(r, g, b):
|
|
37
|
+
"""Calcula luminância relativa conforme WCAG 2.1.
|
|
38
|
+
|
|
39
|
+
sRGB para linear: if c <= 0.04045: c/12.92 else: ((c+0.055)/1.055)^2.4
|
|
40
|
+
L = 0.2126*R + 0.7152*G + 0.0722*B
|
|
41
|
+
"""
|
|
42
|
+
def linearize(c):
|
|
43
|
+
c = c / 255.0
|
|
44
|
+
if c <= 0.04045:
|
|
45
|
+
return c / 12.92
|
|
46
|
+
return ((c + 0.055) / 1.055) ** 2.4
|
|
47
|
+
|
|
48
|
+
rl = linearize(r)
|
|
49
|
+
gl = linearize(g)
|
|
50
|
+
bl = linearize(b)
|
|
51
|
+
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def contrast_ratio(color1, color2):
|
|
55
|
+
"""Calcula ratio de contraste entre duas cores hex.
|
|
56
|
+
|
|
57
|
+
Retorna ratio como float (ex: 4.5).
|
|
58
|
+
"""
|
|
59
|
+
r1, g1, b1 = parse_hex(color1)
|
|
60
|
+
r2, g2, b2 = parse_hex(color2)
|
|
61
|
+
|
|
62
|
+
l1 = relative_luminance(r1, g1, b1)
|
|
63
|
+
l2 = relative_luminance(r2, g2, b2)
|
|
64
|
+
|
|
65
|
+
lighter = max(l1, l2)
|
|
66
|
+
darker = min(l1, l2)
|
|
67
|
+
|
|
68
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_wcag(ratio):
|
|
72
|
+
"""Verifica conformidade WCAG para um ratio."""
|
|
73
|
+
return {
|
|
74
|
+
"aa_normal": ratio >= 4.5,
|
|
75
|
+
"aa_large": ratio >= 3.0,
|
|
76
|
+
"aaa": ratio >= 7.0,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _pass_fail(ok):
|
|
81
|
+
return "PASSA" if ok else "FALHA"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def format_result(fg, bg, ratio, wcag):
|
|
85
|
+
"""Formata resultado de verificação."""
|
|
86
|
+
return (
|
|
87
|
+
f" {fg} sobre {bg}\n"
|
|
88
|
+
f" Ratio: {ratio:.1f}:1 | "
|
|
89
|
+
f"AA normal: {_pass_fail(wcag['aa_normal'])} | "
|
|
90
|
+
f"AA grande: {_pass_fail(wcag['aa_large'])} | "
|
|
91
|
+
f"AAA: {_pass_fail(wcag['aaa'])}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_pair(fg, bg):
|
|
96
|
+
"""Verifica um par de cores e imprime resultado."""
|
|
97
|
+
ratio = contrast_ratio(fg, bg)
|
|
98
|
+
wcag = check_wcag(ratio)
|
|
99
|
+
print(format_result(fg, bg, ratio, wcag))
|
|
100
|
+
return wcag
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_design_colors(design_path):
|
|
104
|
+
"""Extrai cores da seção 'Contrato de Cores' do DESIGN.md.
|
|
105
|
+
|
|
106
|
+
Retorna dict com listas de cores foreground e background.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
with open(design_path, "r", encoding="utf-8") as f:
|
|
110
|
+
content = f.read()
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
print(f"Erro: arquivo não encontrado: {design_path}", file=sys.stderr)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
# Encontra seção de cores
|
|
116
|
+
colors_section = ""
|
|
117
|
+
in_section = False
|
|
118
|
+
for line in content.split("\n"):
|
|
119
|
+
if re.match(r"^#{1,6}\s+.*(?:contrato\s+de\s+cores|cores|paleta|colors)", line, re.IGNORECASE):
|
|
120
|
+
in_section = True
|
|
121
|
+
continue
|
|
122
|
+
if in_section and re.match(r"^#{1,6}\s+", line) and "cor" not in line.lower() and "color" not in line.lower() and "paleta" not in line.lower():
|
|
123
|
+
break
|
|
124
|
+
if in_section:
|
|
125
|
+
colors_section += line + "\n"
|
|
126
|
+
|
|
127
|
+
# Extrai todas as cores hex
|
|
128
|
+
hex_colors = re.findall(r"#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b", colors_section)
|
|
129
|
+
hex_colors = list(dict.fromkeys(hex_colors)) # remove duplicatas mantendo ordem
|
|
130
|
+
|
|
131
|
+
if not hex_colors:
|
|
132
|
+
print("Aviso: nenhuma cor hexadecimal encontrada na seção 'Contrato de Cores'.", file=sys.stderr)
|
|
133
|
+
|
|
134
|
+
return hex_colors
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main():
|
|
138
|
+
parser = argparse.ArgumentParser(
|
|
139
|
+
description="Verificador de contraste WCAG 2.1.",
|
|
140
|
+
epilog="""Exemplos:
|
|
141
|
+
python3 contrast.py "#1a1a2e" "#ffffff"
|
|
142
|
+
python3 contrast.py --fg "#6366f1" --bg "#ffffff" --bg-dark "#1a1a2e"
|
|
143
|
+
python3 contrast.py --design .hefesto/DESIGN.md
|
|
144
|
+
""",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument("color1", nargs="?", help="Cor de primeiro plano (hex)")
|
|
147
|
+
parser.add_argument("color2", nargs="?", help="Cor de fundo (hex)")
|
|
148
|
+
parser.add_argument("--fg", help="Cor de primeiro plano (hex)")
|
|
149
|
+
parser.add_argument("--bg", help="Cor de fundo claro (hex)")
|
|
150
|
+
parser.add_argument("--bg-dark", help="Cor de fundo escuro (hex)")
|
|
151
|
+
parser.add_argument("--design", help="Caminho para DESIGN.md para verificar todos os pares")
|
|
152
|
+
|
|
153
|
+
args = parser.parse_args()
|
|
154
|
+
|
|
155
|
+
print()
|
|
156
|
+
|
|
157
|
+
if args.design:
|
|
158
|
+
colors = extract_design_colors(args.design)
|
|
159
|
+
if len(colors) < 2:
|
|
160
|
+
print("Erro: pelo menos 2 cores são necessárias para verificar contraste.", file=sys.stderr)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
print(f" Verificando {len(colors)} cores de {args.design}\n")
|
|
164
|
+
failures = 0
|
|
165
|
+
total = 0
|
|
166
|
+
for i, fg in enumerate(colors):
|
|
167
|
+
for bg in colors[i + 1:]:
|
|
168
|
+
ratio = contrast_ratio(fg, bg)
|
|
169
|
+
wcag = check_wcag(ratio)
|
|
170
|
+
total += 1
|
|
171
|
+
if not wcag["aa_normal"]:
|
|
172
|
+
failures += 1
|
|
173
|
+
print(format_result(fg, bg, ratio, wcag))
|
|
174
|
+
|
|
175
|
+
print(f"\n Resumo: {total} pares verificados, {total - failures} passam AA normal, {failures} falham.\n")
|
|
176
|
+
|
|
177
|
+
elif args.fg:
|
|
178
|
+
if not args.bg:
|
|
179
|
+
parser.error("--bg é obrigatório quando --fg é usado.")
|
|
180
|
+
|
|
181
|
+
check_pair(args.fg, args.bg)
|
|
182
|
+
if args.bg_dark:
|
|
183
|
+
check_pair(args.fg, args.bg_dark)
|
|
184
|
+
print()
|
|
185
|
+
|
|
186
|
+
elif args.color1 and args.color2:
|
|
187
|
+
check_pair(args.color1, args.color2)
|
|
188
|
+
print()
|
|
189
|
+
|
|
190
|
+
else:
|
|
191
|
+
parser.error("Informe duas cores, use --fg/--bg, ou --design.")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|