hexsweep 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.
- hexsweep/__init__.py +3 -0
- hexsweep/__main__.py +6 -0
- hexsweep/cli.py +141 -0
- hexsweep/core.py +262 -0
- hexsweep/walk.py +73 -0
- hexsweep-0.1.0.dist-info/METADATA +133 -0
- hexsweep-0.1.0.dist-info/RECORD +11 -0
- hexsweep-0.1.0.dist-info/WHEEL +5 -0
- hexsweep-0.1.0.dist-info/entry_points.txt +2 -0
- hexsweep-0.1.0.dist-info/licenses/LICENSE +21 -0
- hexsweep-0.1.0.dist-info/top_level.txt +1 -0
hexsweep/__init__.py
ADDED
hexsweep/__main__.py
ADDED
hexsweep/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from . import core
|
|
8
|
+
from . import walk as walkmod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _mk_paint(on):
|
|
12
|
+
def col(c, s):
|
|
13
|
+
return f"\x1b[{c}m{s}\x1b[0m" if on else s
|
|
14
|
+
return {
|
|
15
|
+
"red": lambda s: col("31", s), "green": lambda s: col("32", s),
|
|
16
|
+
"yellow": lambda s: col("33", s), "dim": lambda s: col("2", s),
|
|
17
|
+
"bold": lambda s: col("1", s), "cyan": lambda s: col("36", s),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _help(p):
|
|
22
|
+
b = p["bold"]
|
|
23
|
+
return (
|
|
24
|
+
f"{b('hexsweep')} — find hardcoded colors that should be design tokens. Zero dependencies.\n"
|
|
25
|
+
"\n"
|
|
26
|
+
"Scans your source for raw color literals (#hex, rgb()/hsl()) that escaped a\n"
|
|
27
|
+
"design-token migration — the `background: #3f82f0` that should be `var(--primary)`.\n"
|
|
28
|
+
"Token definitions (`--primary: #3f82f0`) are allowed by default; usages are flagged.\n"
|
|
29
|
+
"\n"
|
|
30
|
+
f"{b('Usage')}\n"
|
|
31
|
+
" hexsweep [path...] Scan paths (default: current directory)\n"
|
|
32
|
+
" hexsweep src/ --json Machine-readable output, for CI\n"
|
|
33
|
+
"\n"
|
|
34
|
+
f"{b('Options')}\n"
|
|
35
|
+
" --ext css,scss,tsx Override the scanned extension set\n"
|
|
36
|
+
" --allow \"#000,#fff\" Allowlist literals that are OK to hardcode\n"
|
|
37
|
+
" --strict Also flag token DEFINITIONS (--x/$x/@x), not just usages\n"
|
|
38
|
+
" --exclude dirA,dirB Extra directory names to skip (node_modules/.git/dist… already skipped)\n"
|
|
39
|
+
" --json JSON output (byte-identical across the Node and Python builds)\n"
|
|
40
|
+
" --quiet Only print the summary line\n"
|
|
41
|
+
" --no-color Disable ANSI color\n"
|
|
42
|
+
" --help | --version\n"
|
|
43
|
+
"\n"
|
|
44
|
+
f"{b('Exit')} 0 clean · 1 hardcoded colors found · 2 error\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv=None):
|
|
49
|
+
try:
|
|
50
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
51
|
+
except (AttributeError, ValueError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
55
|
+
use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
56
|
+
|
|
57
|
+
def die(msg):
|
|
58
|
+
sys.stderr.write(_mk_paint(use_color)["red"](f"hexsweep: {msg}\n"))
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
dd_idx = argv.index("--") if "--" in argv else len(argv)
|
|
62
|
+
pre_argv = argv[:dd_idx]
|
|
63
|
+
if "-h" in pre_argv or "--help" in pre_argv:
|
|
64
|
+
sys.stdout.write(_help(_mk_paint(use_color)))
|
|
65
|
+
return 0
|
|
66
|
+
if "-v" in pre_argv or "--version" in pre_argv:
|
|
67
|
+
sys.stdout.write(__version__ + "\n")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
as_json = strict = quiet = no_color = False
|
|
71
|
+
exts = None
|
|
72
|
+
allow_list = []
|
|
73
|
+
exclude_dirs = []
|
|
74
|
+
roots = []
|
|
75
|
+
dd = False
|
|
76
|
+
i = 0
|
|
77
|
+
while i < len(argv):
|
|
78
|
+
a = argv[i]
|
|
79
|
+
if dd:
|
|
80
|
+
roots.append(a); i += 1; continue
|
|
81
|
+
if a == "--":
|
|
82
|
+
dd = True; i += 1; continue
|
|
83
|
+
eq = a.find("=")
|
|
84
|
+
flag = a[:eq] if (a.startswith("--") and eq != -1) else a
|
|
85
|
+
inline = a[eq + 1:] if (a.startswith("--") and eq != -1) else None
|
|
86
|
+
|
|
87
|
+
def take():
|
|
88
|
+
nonlocal i
|
|
89
|
+
if inline is not None:
|
|
90
|
+
return inline
|
|
91
|
+
i += 1
|
|
92
|
+
return argv[i] if i < len(argv) else ""
|
|
93
|
+
|
|
94
|
+
if flag == "--json":
|
|
95
|
+
as_json = True
|
|
96
|
+
elif flag == "--strict":
|
|
97
|
+
strict = True
|
|
98
|
+
elif flag == "--quiet":
|
|
99
|
+
quiet = True
|
|
100
|
+
elif flag == "--no-color":
|
|
101
|
+
no_color = True
|
|
102
|
+
elif flag == "--ext":
|
|
103
|
+
exts = [t for t in (s.strip().lstrip(".") for s in take().split(",")) if t]
|
|
104
|
+
elif flag == "--allow":
|
|
105
|
+
allow_list += core.split_color_list(take())
|
|
106
|
+
elif flag == "--exclude":
|
|
107
|
+
exclude_dirs += [s.strip() for s in take().split(",") if s.strip()]
|
|
108
|
+
elif a in ("-h", "--help", "-v", "--version"):
|
|
109
|
+
pass
|
|
110
|
+
elif a.startswith("-") and a != "-":
|
|
111
|
+
return die(f"unknown option: {a} (use -- to end options)")
|
|
112
|
+
else:
|
|
113
|
+
roots.append(a)
|
|
114
|
+
i += 1
|
|
115
|
+
|
|
116
|
+
if not roots:
|
|
117
|
+
roots.append(".")
|
|
118
|
+
scan_exts = exts if exts else walkmod.DEFAULT_EXTS
|
|
119
|
+
allow = core.build_allow(allow_list)
|
|
120
|
+
paint = _mk_paint(use_color and not no_color)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
files = walkmod.collect_files(roots, scan_exts, exclude_dirs)
|
|
124
|
+
except OSError as e:
|
|
125
|
+
return die(str(e))
|
|
126
|
+
|
|
127
|
+
findings = []
|
|
128
|
+
for f in files:
|
|
129
|
+
text = walkmod.read_file_text(f)
|
|
130
|
+
if text is None:
|
|
131
|
+
continue
|
|
132
|
+
ext = (f.rsplit(".", 1)[-1] if "." in f else "").lower()
|
|
133
|
+
findings += core.scan_text(text, walkmod.to_posix(f), ext, {"allow": allow, "strict": strict})
|
|
134
|
+
findings = core.sort_findings(findings)
|
|
135
|
+
summary = core.summarize(findings, len(files))
|
|
136
|
+
|
|
137
|
+
if as_json:
|
|
138
|
+
sys.stdout.write(json.dumps(core.to_json(findings, summary), indent=2, ensure_ascii=False) + "\n")
|
|
139
|
+
else:
|
|
140
|
+
sys.stdout.write(core.format_report(findings, summary, paint, {"quiet": quiet}) + "\n")
|
|
141
|
+
return 1 if findings else 0
|
hexsweep/core.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""hexsweep core — pure scanning logic. No fs, no walking, no clock.
|
|
2
|
+
|
|
3
|
+
Finds hardcoded color literals (hex + rgb/hsl functional notation) that should be
|
|
4
|
+
design tokens. The differentiation vs a raw grep lives here: comment-aware
|
|
5
|
+
blanking (but strings are KEPT, since CSS-in-JS colors live in strings), an
|
|
6
|
+
id-selector / url() gate, an allowlist, and a "definition vs usage" split that
|
|
7
|
+
exempts ``--token: #fff`` token definitions by default.
|
|
8
|
+
|
|
9
|
+
Every regex uses explicit ASCII classes ([0-9A-Fa-f], never \\d/\\w/\\s/\\b) and
|
|
10
|
+
columns are counted in UTF-8 bytes, so the Node and Python builds emit
|
|
11
|
+
byte-for-byte identical output.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
# Hex: 3/4/6/8 digits, longest-first; left guard blocks #id chaining / $interp /
|
|
17
|
+
# .class / SHAs-with-#; right guard blocks 5/7-digit runs and word continuation.
|
|
18
|
+
HEX = r"(?<![0-9A-Za-z_#.$-])#(?:[0-9A-Fa-f]{8}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{3})(?![0-9A-Za-z_])"
|
|
19
|
+
# Functional rgb()/rgba()/hsl()/hsla() — explicit case classes (no re.I), single
|
|
20
|
+
# line. The left guard (like HEX's) stops identifiers ending in rgb/hsl (brgb(), to-hsl()).
|
|
21
|
+
FUNC = r"(?<![0-9A-Za-z_$@-])(?:[Rr][Gg][Bb][Aa]?|[Hh][Ss][Ll][Aa]?)\([^)\n]*\)"
|
|
22
|
+
COLOR_RE = re.compile(HEX + "|" + FUNC)
|
|
23
|
+
|
|
24
|
+
_DECL_RE = re.compile(r"([-_$@A-Za-z0-9]+)[ \t]*:[ \t]*[^:]*$")
|
|
25
|
+
_BRACE_RE = re.compile(r"[ \t]*\{")
|
|
26
|
+
_COMMA_RE = re.compile(r"[ \t]*,")
|
|
27
|
+
_URL_RE = re.compile(r"url\([ \t]*['\"]?$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def comment_styles(ext):
|
|
31
|
+
e = str(ext).lower()
|
|
32
|
+
js = e in ("js", "jsx", "ts", "tsx", "mjs", "cjs")
|
|
33
|
+
css_pre = e in ("scss", "sass", "less")
|
|
34
|
+
css = e == "css"
|
|
35
|
+
html = e in ("html", "htm", "vue", "svelte", "astro")
|
|
36
|
+
return {
|
|
37
|
+
"strings": js or css_pre or css,
|
|
38
|
+
"template": js,
|
|
39
|
+
"line": js or css_pre,
|
|
40
|
+
"block": js or css_pre or css or html,
|
|
41
|
+
"html": html,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def strip_comments(text, ext):
|
|
46
|
+
"""Replace comment characters with spaces (newlines preserved) so the color
|
|
47
|
+
regex never matches inside a comment, keeping string contents intact and all
|
|
48
|
+
positions 1:1. Iterates by code point (== Node Array.from) for byte-stable
|
|
49
|
+
columns even with astral chars inside comments."""
|
|
50
|
+
st = comment_styles(ext)
|
|
51
|
+
chars = list(text)
|
|
52
|
+
n = len(chars)
|
|
53
|
+
out = []
|
|
54
|
+
state = "normal"
|
|
55
|
+
delim = ""
|
|
56
|
+
i = 0
|
|
57
|
+
|
|
58
|
+
def at(k):
|
|
59
|
+
return chars[k] if k < n else ""
|
|
60
|
+
|
|
61
|
+
def blank(c):
|
|
62
|
+
return "\n" if c == "\n" else " "
|
|
63
|
+
|
|
64
|
+
while i < n:
|
|
65
|
+
c = chars[i]
|
|
66
|
+
c2 = at(i + 1)
|
|
67
|
+
if state == "normal":
|
|
68
|
+
if st["strings"] and (c == "'" or c == '"' or (st["template"] and c == "`")):
|
|
69
|
+
state = "str"; delim = c; out.append(c); i += 1; continue
|
|
70
|
+
if st["line"] and c == "/" and c2 == "/":
|
|
71
|
+
state = "line"; out.append(" "); out.append(" "); i += 2; continue
|
|
72
|
+
if st["block"] and c == "/" and c2 == "*":
|
|
73
|
+
state = "block"; out.append(" "); out.append(" "); i += 2; continue
|
|
74
|
+
if st["html"] and c == "<" and c2 == "!" and at(i + 2) == "-" and at(i + 3) == "-":
|
|
75
|
+
state = "html"; out.extend([" ", " ", " ", " "]); i += 4; continue
|
|
76
|
+
out.append(c); i += 1; continue
|
|
77
|
+
if state == "str":
|
|
78
|
+
if c == "\\":
|
|
79
|
+
out.append(c)
|
|
80
|
+
if i + 1 < n:
|
|
81
|
+
out.append(chars[i + 1])
|
|
82
|
+
i += 2; continue
|
|
83
|
+
if c == delim:
|
|
84
|
+
state = "normal"; out.append(c); i += 1; continue
|
|
85
|
+
out.append(c); i += 1; continue
|
|
86
|
+
if state == "line":
|
|
87
|
+
if c == "\n":
|
|
88
|
+
state = "normal"; out.append("\n"); i += 1; continue
|
|
89
|
+
out.append(blank(c)); i += 1; continue
|
|
90
|
+
if state == "block":
|
|
91
|
+
if c == "*" and c2 == "/":
|
|
92
|
+
state = "normal"; out.append(" "); out.append(" "); i += 2; continue
|
|
93
|
+
out.append(blank(c)); i += 1; continue
|
|
94
|
+
if state == "html":
|
|
95
|
+
if c == "-" and c2 == "-" and at(i + 2) == ">":
|
|
96
|
+
state = "normal"; out.extend([" ", " ", " "]); i += 3; continue
|
|
97
|
+
out.append(blank(c)); i += 1; continue
|
|
98
|
+
return "".join(out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def to_lines(text):
|
|
102
|
+
t = text
|
|
103
|
+
if t and t[0] == "":
|
|
104
|
+
t = t[1:]
|
|
105
|
+
t = t.replace("\r\n", "\n").replace("\r", "\n")
|
|
106
|
+
return t.split("\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def byte_len(s):
|
|
110
|
+
return len(s.encode("utf-8"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def category_of(literal):
|
|
114
|
+
if literal[0] == "#":
|
|
115
|
+
return "hex" + str(len(literal) - 1)
|
|
116
|
+
return re.match(r"[A-Za-z]+", literal).group(0).lower()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def normalize_color(lit):
|
|
120
|
+
s = lit.lower()
|
|
121
|
+
if s[0] == "#":
|
|
122
|
+
h = s[1:]
|
|
123
|
+
if len(h) in (3, 4):
|
|
124
|
+
return "#" + "".join(ch * 2 for ch in h)
|
|
125
|
+
return s
|
|
126
|
+
return re.sub(r"[ \t]", "", s)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def property_and_definition(pre):
|
|
130
|
+
frag = re.split(r"[;{]", pre)[-1]
|
|
131
|
+
m = _DECL_RE.search(frag)
|
|
132
|
+
if not m:
|
|
133
|
+
return (None, False)
|
|
134
|
+
prop = m.group(1)
|
|
135
|
+
is_def = prop.startswith("--") or prop.startswith("$") or prop.startswith("@")
|
|
136
|
+
return (prop, is_def)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_structural_hex(pre, post):
|
|
140
|
+
# Followed by `{` => id-selector (a color value never is). Handles
|
|
141
|
+
# `#fff {`, `input:focus #fff {`, minified `}#b{`.
|
|
142
|
+
if _BRACE_RE.match(post):
|
|
143
|
+
return True
|
|
144
|
+
# Followed by `,` => selector list OR value list; a selector only if its own
|
|
145
|
+
# fragment (after the last , { ;) has no `:` value separator.
|
|
146
|
+
if _COMMA_RE.match(post) and re.split(r"[,{;]", pre)[-1].find(":") == -1:
|
|
147
|
+
return True
|
|
148
|
+
if _URL_RE.search(pre):
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def scan_text(text, file, ext, opts):
|
|
154
|
+
allow = opts.get("allow") or set()
|
|
155
|
+
strict = bool(opts.get("strict"))
|
|
156
|
+
lines = to_lines(strip_comments(text, ext))
|
|
157
|
+
findings = []
|
|
158
|
+
for li, line in enumerate(lines):
|
|
159
|
+
for m in COLOR_RE.finditer(line):
|
|
160
|
+
literal = m.group(0)
|
|
161
|
+
start = m.start()
|
|
162
|
+
pre = line[:start]
|
|
163
|
+
post = line[start + len(literal):]
|
|
164
|
+
if literal[0] == "#" and is_structural_hex(pre, post):
|
|
165
|
+
continue
|
|
166
|
+
if normalize_color(literal) in allow:
|
|
167
|
+
continue
|
|
168
|
+
prop, is_def = property_and_definition(pre)
|
|
169
|
+
if is_def and not strict:
|
|
170
|
+
continue
|
|
171
|
+
findings.append({
|
|
172
|
+
"file": file, "line": li + 1, "column": byte_len(pre) + 1,
|
|
173
|
+
"literal": literal, "category": category_of(literal),
|
|
174
|
+
"property": prop, "isDefinition": is_def,
|
|
175
|
+
})
|
|
176
|
+
return findings
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def sort_findings(findings):
|
|
180
|
+
return sorted(findings, key=lambda f: (f["file"].encode("utf-8"), f["line"], f["column"]))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_allow(items):
|
|
184
|
+
s = set()
|
|
185
|
+
for item in items or []:
|
|
186
|
+
t = item.strip()
|
|
187
|
+
if t:
|
|
188
|
+
s.add(normalize_color(t))
|
|
189
|
+
return s
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def split_color_list(s):
|
|
193
|
+
"""Split a comma list, but NOT on commas inside rgb()/hsl() parens."""
|
|
194
|
+
out = []
|
|
195
|
+
cur = ""
|
|
196
|
+
depth = 0
|
|
197
|
+
for ch in str(s):
|
|
198
|
+
if ch == "(":
|
|
199
|
+
depth += 1
|
|
200
|
+
cur += ch
|
|
201
|
+
elif ch == ")":
|
|
202
|
+
if depth > 0:
|
|
203
|
+
depth -= 1
|
|
204
|
+
cur += ch
|
|
205
|
+
elif ch == "," and depth == 0:
|
|
206
|
+
out.append(cur)
|
|
207
|
+
cur = ""
|
|
208
|
+
else:
|
|
209
|
+
cur += ch
|
|
210
|
+
out.append(cur)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def summarize(findings, files_scanned):
|
|
215
|
+
files = {f["file"] for f in findings}
|
|
216
|
+
return {"filesScanned": files_scanned, "filesWithFindings": len(files), "findingCount": len(findings)}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def to_json(findings, summary):
|
|
220
|
+
return {
|
|
221
|
+
"version": 1,
|
|
222
|
+
"summary": summary,
|
|
223
|
+
"findings": [{
|
|
224
|
+
"file": f["file"], "line": f["line"], "column": f["column"], "literal": f["literal"],
|
|
225
|
+
"category": f["category"], "property": f["property"], "isDefinition": f["isDefinition"],
|
|
226
|
+
} for f in findings],
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
PLAIN = {"red": lambda s: s, "green": lambda s: s, "yellow": lambda s: s,
|
|
231
|
+
"dim": lambda s: s, "bold": lambda s: s, "cyan": lambda s: s}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def format_report(findings, summary, paint=None, opts=None):
|
|
235
|
+
p = paint or PLAIN
|
|
236
|
+
quiet = bool(opts and opts.get("quiet"))
|
|
237
|
+
if not findings:
|
|
238
|
+
n = summary["filesScanned"]
|
|
239
|
+
return p["green"]("✓ no hardcoded colors found") + f" ({n} file{'' if n == 1 else 's'} scanned)"
|
|
240
|
+
lines = []
|
|
241
|
+
if not quiet:
|
|
242
|
+
current = None
|
|
243
|
+
for f in findings:
|
|
244
|
+
if f["file"] != current:
|
|
245
|
+
if current is not None:
|
|
246
|
+
lines.append("")
|
|
247
|
+
current = f["file"]
|
|
248
|
+
cnt = sum(1 for x in findings if x["file"] == f["file"])
|
|
249
|
+
lines.append(p["bold"](f["file"]) + p["dim"](f" ({cnt})"))
|
|
250
|
+
loc = p["dim"](f"{f['line']}:{f['column']}")
|
|
251
|
+
lit = p["yellow"](f["literal"])
|
|
252
|
+
tag = p["cyan"](f"[{f['category']}]")
|
|
253
|
+
prop = p["dim"](f" {f['property']}") if f["property"] else ""
|
|
254
|
+
lines.append(f" {loc} {lit} {tag}{prop}")
|
|
255
|
+
lines.append("")
|
|
256
|
+
fc = summary["findingCount"]
|
|
257
|
+
ff = summary["filesWithFindings"]
|
|
258
|
+
clean = summary["filesScanned"] - ff
|
|
259
|
+
lines.append(
|
|
260
|
+
p["red"](f"✖ {fc} hardcoded color{'' if fc == 1 else 's'} in {ff} file{'' if ff == 1 else 's'}")
|
|
261
|
+
+ p["dim"](f" ({clean} file{'' if clean == 1 else 's'} clean)"))
|
|
262
|
+
return "\n".join(lines)
|
hexsweep/walk.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""hexsweep IO — directory walking and file reading. No scanning logic.
|
|
2
|
+
|
|
3
|
+
Both builds must discover the SAME set of files (findings are sorted by path
|
|
4
|
+
afterwards, so visit order doesn't affect output — but the file SET must match):
|
|
5
|
+
identical ignore dirs, identical extension filter, identical symlink policy
|
|
6
|
+
(never follow), and identical "skip non-UTF-8 / binary" policy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import stat as _stat
|
|
11
|
+
|
|
12
|
+
IGNORE_DIRS = {
|
|
13
|
+
"node_modules", ".git", ".svn", ".hg", "dist", "build", "out",
|
|
14
|
+
"coverage", ".next", ".nuxt", "vendor", ".cache",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
DEFAULT_EXTS = [
|
|
18
|
+
"css", "scss", "sass", "less", "vue", "svelte",
|
|
19
|
+
"jsx", "tsx", "js", "ts", "html", "htm", "astro",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_posix(p):
|
|
24
|
+
s = p.replace(os.sep, "/")
|
|
25
|
+
return s[2:] if s.startswith("./") else s # normalize `.`-root like Node path.join does
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def collect_files(roots, exts, exclude_dirs):
|
|
29
|
+
ext_set = {e.lower() for e in exts}
|
|
30
|
+
ignore = set(IGNORE_DIRS) | set(exclude_dirs or [])
|
|
31
|
+
out = []
|
|
32
|
+
|
|
33
|
+
def visit(p, is_root):
|
|
34
|
+
try:
|
|
35
|
+
st = os.stat(p) if is_root else os.lstat(p)
|
|
36
|
+
except OSError:
|
|
37
|
+
return
|
|
38
|
+
if not is_root and _stat.S_ISLNK(st.st_mode):
|
|
39
|
+
return # never follow symlinks during recursion
|
|
40
|
+
if _stat.S_ISDIR(st.st_mode):
|
|
41
|
+
if not is_root and os.path.basename(p) in ignore:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
entries = os.listdir(p)
|
|
45
|
+
except OSError:
|
|
46
|
+
return
|
|
47
|
+
entries.sort(key=lambda s: s.encode("utf-8"))
|
|
48
|
+
for e in entries:
|
|
49
|
+
visit(os.path.join(p, e), False)
|
|
50
|
+
elif _stat.S_ISREG(st.st_mode):
|
|
51
|
+
ext = os.path.splitext(p)[1][1:].lower()
|
|
52
|
+
if ext in ext_set:
|
|
53
|
+
out.append(p)
|
|
54
|
+
|
|
55
|
+
for r in roots:
|
|
56
|
+
visit(r, True)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_file_text(p):
|
|
61
|
+
"""Read a file as UTF-8 text, or None if binary / not valid UTF-8 (skipped
|
|
62
|
+
identically to the Node build, which detects invalid bytes by re-encoding)."""
|
|
63
|
+
try:
|
|
64
|
+
with open(p, "rb") as fh:
|
|
65
|
+
buf = fh.read()
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
68
|
+
if b"\x00" in buf:
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return buf.decode("utf-8")
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
return None
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hexsweep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find hardcoded colors that should be design tokens — the raw #hex / rgb() that escaped your token migration. Comment- and definition-aware, zero dependencies.
|
|
5
|
+
Author: yyfjj
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jjdoor/hexsweep-py
|
|
8
|
+
Project-URL: Repository, https://github.com/jjdoor/hexsweep-py
|
|
9
|
+
Project-URL: Issues, https://github.com/jjdoor/hexsweep-py/issues
|
|
10
|
+
Keywords: hex,color,lint,linter,css,design-tokens,design-system,cli,frontend
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# hexsweep
|
|
25
|
+
|
|
26
|
+
**Find the hardcoded colors that should be design tokens.** After a token
|
|
27
|
+
migration, components still have raw `background: #3f82f0` buried in them instead
|
|
28
|
+
of `var(--primary-blue)` — and you don't find out until dark mode or a rebrand
|
|
29
|
+
breaks. `hexsweep` scans your source and flags every raw color literal that
|
|
30
|
+
escaped, with zero config and zero dependencies.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pipx run hexsweep src/
|
|
34
|
+
# src/components/Button.tsx (2)
|
|
35
|
+
# 14:18 #3f82f0 [hex6] background
|
|
36
|
+
# 27:10 rgb(255, 0, 0) [rgb] color
|
|
37
|
+
#
|
|
38
|
+
# ✖ 2 hardcoded colors in 1 file (23 files clean)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Exits non-zero when it finds colors, so it drops straight into CI. Pure standard
|
|
42
|
+
library. Also on npm (`npx hexsweep`) — the two builds produce **byte-for-byte
|
|
43
|
+
identical** output.
|
|
44
|
+
|
|
45
|
+
## Why not grep or stylelint?
|
|
46
|
+
|
|
47
|
+
`grep '#[0-9a-f]{6}'` is what people fall back to, and it's noisy: it hits
|
|
48
|
+
`#header` id-selectors, `url(#gradient)` references, colors in comments, and it
|
|
49
|
+
can't see `rgb()`/`hsl()`, 3/4/8-digit hex, or tell a leftover from a token
|
|
50
|
+
definition.
|
|
51
|
+
|
|
52
|
+
**stylelint** can do it, but needs Node + a config + postcss/custom-syntax, it
|
|
53
|
+
can't see hex inside TSX/CSS-in-JS string literals, and — critically — its
|
|
54
|
+
`color-no-hex` flags your `tokens.css` (the one file that's *supposed* to hold
|
|
55
|
+
raw colors) exactly as loudly as a stray hex in a component. The request to
|
|
56
|
+
exempt token definitions has sat unimplemented for years.
|
|
57
|
+
|
|
58
|
+
`hexsweep` sits in between. It's a one-shot, zero-config scanner that is:
|
|
59
|
+
|
|
60
|
+
- **comment-aware** — colors in `//`, `/* */`, `<!-- -->` are skipped (but
|
|
61
|
+
strings are kept, so it *does* catch CSS-in-JS like `` styled.div`color:#3f82f0` ``);
|
|
62
|
+
- **definition-aware** — `--primary: #3f82f0`, `$brand: …`, `@brand: …` are
|
|
63
|
+
allowed by default; only *usages* are flagged (use `--strict` to flag
|
|
64
|
+
definitions too);
|
|
65
|
+
- **structural** — `#fff {` id-selectors and `url(#a1b2c3)` are not colors;
|
|
66
|
+
- **precise** — matches hex (3/4/6/8) + `rgb()/rgba()/hsl()/hsla()`, never a
|
|
67
|
+
5- or 7-digit run, with an `--allow` list for the colors you keep on purpose.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
hexsweep # scan the current directory
|
|
73
|
+
hexsweep src/ styles/ # scan specific paths
|
|
74
|
+
hexsweep --allow "#000,#fff" src # allowlist colors that are OK to hardcode
|
|
75
|
+
hexsweep --strict # also flag token definitions (--x/$x/@x)
|
|
76
|
+
hexsweep --ext css,scss,vue # override the scanned extensions
|
|
77
|
+
hexsweep --json # machine output (byte-identical both builds)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`node_modules`, `.git`, `dist`, `build`, `coverage` (and more) are skipped by
|
|
81
|
+
default; add others with `--exclude`. The default extensions are css, scss,
|
|
82
|
+
sass, less, vue, svelte, jsx, tsx, js, ts, html, htm, astro.
|
|
83
|
+
|
|
84
|
+
Exit codes: `0` clean · `1` hardcoded colors found · `2` error.
|
|
85
|
+
|
|
86
|
+
### In CI
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
- run: pipx run hexsweep src/ # fails the build on a hardcoded color
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The `--json` shape is sorted by `(file, line, column)` so it's deterministic
|
|
93
|
+
regardless of filesystem order:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"version": 1,
|
|
98
|
+
"summary": { "filesScanned": 24, "filesWithFindings": 1, "findingCount": 2 },
|
|
99
|
+
"findings": [
|
|
100
|
+
{ "file": "src/components/Button.tsx", "line": 14, "column": 18,
|
|
101
|
+
"literal": "#3f82f0", "category": "hex6", "property": "background", "isDefinition": false }
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
It's a line scanner, not a CSS/JS parser — that's what keeps it zero-dependency
|
|
109
|
+
and lets the Node and Python builds stay byte-identical. It blanks comment spans
|
|
110
|
+
(keeping string contents and every column position intact), then matches color
|
|
111
|
+
literals with explicit-ASCII regexes, and applies the id-selector / url() /
|
|
112
|
+
definition / allowlist gates. Columns are counted in UTF-8 bytes and files are
|
|
113
|
+
visited in a fixed byte-sorted order, so output never depends on the OS or
|
|
114
|
+
filesystem.
|
|
115
|
+
|
|
116
|
+
## Scope
|
|
117
|
+
|
|
118
|
+
MVP matches `#hex` and `rgb()/hsl()` functional notation. Named colors (`red`),
|
|
119
|
+
modern `oklch()/lab()`, autofix, and a config file are intentionally left out —
|
|
120
|
+
the goal is a high-signal, zero-config CI gate, not a parser.
|
|
121
|
+
|
|
122
|
+
## Install
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pip install hexsweep # or pipx run hexsweep
|
|
126
|
+
npm i -g hexsweep # Node build, identical behaviour
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Python ≥ 3.8 or Node ≥ 18. No dependencies.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
hexsweep/__init__.py,sha256=lmkLUtnktXXbR9m9sY6Bwwic-OXZgN_Gokml3LcAFME,113
|
|
2
|
+
hexsweep/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
hexsweep/cli.py,sha256=VMY1NRK0cNAYZv69-yZfYCQA8zbtxESaza9rBmEl0hI,4977
|
|
4
|
+
hexsweep/core.py,sha256=3is4cTuSsmU16RDxBn6P_JXxWTnhpzgztw96KGsEwrQ,9222
|
|
5
|
+
hexsweep/walk.py,sha256=-pZb-_XWkSOG1urW6rMDzahxsMT3r3D9kJYEE3TfaYM,2249
|
|
6
|
+
hexsweep-0.1.0.dist-info/licenses/LICENSE,sha256=0HXg0heWs3-MqZ68PtVe-tGn7y_P-E2_ySy9-JgTH2k,1078
|
|
7
|
+
hexsweep-0.1.0.dist-info/METADATA,sha256=FyZjr_mBUvpOvqhhLbWzgeSUqMOZr3Ni2R__4EODCqw,5212
|
|
8
|
+
hexsweep-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
hexsweep-0.1.0.dist-info/entry_points.txt,sha256=HOScmJ_6BHGIXoHksYmvxdXROhGgYrU9SarktyLs1iw,47
|
|
10
|
+
hexsweep-0.1.0.dist-info/top_level.txt,sha256=2nzto8z1l4sdw7RkvPz_yvcbDiC8E-UPFQF_lMYnXPM,9
|
|
11
|
+
hexsweep-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hexsweep contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hexsweep
|