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 ADDED
@@ -0,0 +1,3 @@
1
+ """hexsweep — find hardcoded colors that should be design tokens. Zero dependencies."""
2
+
3
+ __version__ = "0.1.0"
hexsweep/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hexsweep = hexsweep.cli:main
@@ -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