fxcss 0.6.1__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.
fxcss/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """fxcss — a testing toolkit for Firefox userChrome.css themes."""
2
+
3
+ __version__ = "0.6.1"
fxcss/__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())
fxcss/audit.py ADDED
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env python3
2
+ """Find selectors in a theme that no longer match anything, and suggest fixes.
3
+
4
+ Firefox renames and removes chrome elements between releases, and a rule that
5
+ targets a name which no longer exists fails silently -- the theme just stops
6
+ styling that part, with no error anywhere. This walks every id and class a theme
7
+ mentions, resolves each against a running Firefox, and reports the ones that
8
+ resolve to nothing, with a suggested replacement where one can be inferred.
9
+
10
+ Suggestions are derived from the live browser rather than a hardcoded list, so
11
+ they stay correct as Firefox changes:
12
+
13
+ renamed the same name exists, but as a class instead of an id (or the
14
+ reverse). This is the common case and the suggestion is exact.
15
+ similar no exact counterpart, but a close name exists -- usually a typo
16
+ in the theme, or a name that gained or lost a suffix.
17
+ unresolved nothing close. Reported separately and not counted as a problem,
18
+ because it is usually an element that only appears in a state
19
+ this tool cannot reach rather than one that has been removed.
20
+
21
+ `changelog` runs the same collection against two Firefox builds and diffs them,
22
+ which is how you find what a new release changed before it reaches users.
23
+ """
24
+
25
+ import difflib
26
+ import re
27
+ import time
28
+ from pathlib import Path
29
+
30
+ COMMENT = re.compile(r"/\*.*?\*/", re.S)
31
+ URLS = re.compile(r"url\([^)]*\)")
32
+ STRINGS = re.compile(r"(['\"])(?:\\.|(?!\1).)*\1", re.S)
33
+ TOKEN = re.compile(r"(?<![\w-])([#.])([A-Za-z_][\w-]*)")
34
+ HEXCOLOR = re.compile(r"^[0-9a-fA-F]+$")
35
+
36
+ # Selectors that intentionally match nothing. `:not(#hack)` is a well-known
37
+ # trick for raising specificity without changing what a rule matches, so
38
+ # flagging it as a dead selector would be noise.
39
+ SPECIFICITY_HACKS = {"#hack", "#nope", "#never", "#no", "#none", "#fake"}
40
+
41
+
42
+ def _looks_like_colour(kind, name):
43
+ return kind == "#" and len(name) in (3, 4, 6, 8) and HEXCOLOR.match(name)
44
+
45
+
46
+ def extract_tokens(theme: Path):
47
+ """Map every id/class token in the theme to where it is written.
48
+
49
+ Deliberately token-level rather than whole-selector: Firefox breaks themes
50
+ by renaming individual ids and classes, and a whole selector containing
51
+ `&` nesting or `::part()` cannot be handed to querySelectorAll anyway.
52
+ """
53
+ found = {}
54
+ for path in sorted((theme / "chrome").rglob("*.css")):
55
+ try:
56
+ raw = path.read_text(encoding="utf-8", errors="replace")
57
+ except OSError:
58
+ continue
59
+
60
+ # Blank comments across the whole file, not per line: a block comment
61
+ # spans lines, and a selector named in prose is not a selector. Newlines
62
+ # are preserved so reported line numbers still line up with the source.
63
+ blanked = COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), raw)
64
+ source_lines = raw.splitlines()
65
+
66
+ for index, scanned in enumerate(blanked.splitlines()):
67
+ cleaned = STRINGS.sub("''", URLS.sub("url()", scanned))
68
+ # Skip declaration-only lines so a property value cannot be mistaken
69
+ # for a selector.
70
+ if "{" not in cleaned and ";" in cleaned and ":" in cleaned:
71
+ continue
72
+ for kind, name in TOKEN.findall(cleaned):
73
+ if _looks_like_colour(kind, name):
74
+ continue
75
+ token = kind + name
76
+ if token in SPECIFICITY_HACKS:
77
+ continue
78
+ found.setdefault(token, []).append({
79
+ "file": str(path.relative_to(theme)),
80
+ "line": index + 1,
81
+ "text": source_lines[index].rstrip(),
82
+ })
83
+ return found
84
+
85
+
86
+ COLLECT_DOM = """
87
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
88
+ const doc = win.document;
89
+ const ids = new Set(), classes = new Set();
90
+ for (const el of doc.querySelectorAll("*")) {
91
+ if (el.id) ids.add(el.id);
92
+ const c = el.getAttribute("class");
93
+ if (c) { for (const x of c.trim().split(/\\s+/)) classes.add(x); }
94
+ }
95
+ return {ids: [...ids], classes: [...classes]};
96
+ """
97
+
98
+ # Large parts of browser.xhtml are built lazily -- the app menu's contents do
99
+ # not exist as elements until the menu has been opened once. Collecting from a
100
+ # single resting state would report hundreds of live elements as missing.
101
+ OPEN_APPMENU = """
102
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
103
+ try { win.PanelUI.show(); } catch (e) {}
104
+ return true;
105
+ """
106
+
107
+ CLOSE_APPMENU = """
108
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
109
+ try { win.PanelUI.hide(); } catch (e) {}
110
+ return true;
111
+ """
112
+
113
+ OPEN_CONTEXT_MENUS = """
114
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
115
+ const doc = win.document;
116
+ let opened = 0;
117
+ for (const id of ["tabContextMenu", "contentAreaContextMenu", "toolbar-context-menu"]) {
118
+ const popup = doc.getElementById(id);
119
+ if (!popup) { continue; }
120
+ try {
121
+ popup.openPopupAtScreen(win.screenX + 60, win.screenY + 120, false);
122
+ popup.hidePopup();
123
+ opened++;
124
+ } catch (e) {}
125
+ }
126
+ return opened;
127
+ """
128
+
129
+ URLBAR_RESULTS = """
130
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
131
+ win.gURLBar.focus();
132
+ win.gURLBar.value = "a";
133
+ try { win.gURLBar.startQuery({searchString: "a", allowAutofill: false}); } catch (e) {}
134
+ return true;
135
+ """
136
+
137
+
138
+ def collect_dom(session, verbose=True):
139
+ """Union of every id and class present across the states we can produce."""
140
+ from . import core
141
+
142
+ ids, classes = set(), set()
143
+
144
+ def sweep(label):
145
+ result = session.m.script(COLLECT_DOM)
146
+ ids.update(result["ids"])
147
+ classes.update(result["classes"])
148
+ if verbose:
149
+ print(f" {label:<22} {len(ids)} ids, {len(classes)} classes", flush=True)
150
+
151
+ session.setup_window()
152
+ time.sleep(2.0)
153
+ sweep("resting")
154
+
155
+ session.m.script(core.OPEN_FINDBAR)
156
+ time.sleep(1.2)
157
+ sweep("find bar open")
158
+
159
+ session.m.script(URLBAR_RESULTS)
160
+ time.sleep(1.5)
161
+ sweep("address bar results")
162
+ session.m.script(core.BLUR_URLBAR)
163
+
164
+ session.m.script(OPEN_APPMENU)
165
+ time.sleep(2.0)
166
+ sweep("app menu opened")
167
+ session.m.script(CLOSE_APPMENU)
168
+ time.sleep(0.8)
169
+
170
+ session.m.script(OPEN_CONTEXT_MENUS)
171
+ time.sleep(1.5)
172
+ sweep("context menus built")
173
+
174
+ session.set_dark(True)
175
+ time.sleep(1.5)
176
+ sweep("dark mode")
177
+
178
+ return {"ids": ids, "classes": classes}
179
+
180
+
181
+ def _differing_chars(a, b):
182
+ """Characters that differ between two names, ignoring shared runs."""
183
+ matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
184
+ matched = sum(block.size for block in matcher.get_matching_blocks())
185
+ return (len(a) - matched) + (len(b) - matched)
186
+
187
+
188
+ def _is_near_miss(token_name, candidate):
189
+ """Is this close enough to be the same element under a new name?
190
+
191
+ A plain similarity ratio is not enough. Chrome ids share long scaffolding
192
+ (`appMenu-…-button`), so `appMenu-paste-button` and
193
+ `appMenu-translate-button` score highly while being unrelated controls --
194
+ suggesting one for the other would be worse than saying nothing.
195
+
196
+ Two shapes are trustworthy: a short suffix appearing or disappearing, which
197
+ is how Firefox versions its chrome (`…-button` became `…-button2`), and a
198
+ difference of a character or two, which is a typo.
199
+ """
200
+ shorter, longer = sorted((token_name, candidate), key=len)
201
+ if longer.startswith(shorter) and len(longer) - len(shorter) <= 2:
202
+ return True
203
+ return _differing_chars(token_name, candidate) <= 2
204
+
205
+
206
+ def suggest(token, dom):
207
+ """Infer a replacement for a token that matches nothing."""
208
+ kind, name = token[0], token[1:]
209
+ ids, classes = dom["ids"], dom["classes"]
210
+
211
+ # An id that is now a class, or the reverse. Exact and by far the commonest.
212
+ if kind == "#" and name in classes:
213
+ return {"replacement": "." + name, "confidence": "renamed",
214
+ "reason": "same name, now a class rather than an id"}
215
+ if kind == "." and name in ids:
216
+ return {"replacement": "#" + name, "confidence": "renamed",
217
+ "reason": "same name, now an id rather than a class"}
218
+
219
+ # A near-miss in the same namespace: usually a typo, or a suffix change.
220
+ pool = sorted(ids if kind == "#" else classes)
221
+ close = [c for c in difflib.get_close_matches(name, pool, n=4, cutoff=0.80)
222
+ if _is_near_miss(name, c)][:1]
223
+ if close:
224
+ return {"replacement": kind + close[0], "confidence": "similar",
225
+ "reason": f"no exact match; closest live name is {kind}{close[0]}"}
226
+
227
+ # Same name in the other namespace but only as a near-miss.
228
+ other = sorted(classes if kind == "#" else ids)
229
+ close = [c for c in difflib.get_close_matches(name, other, n=4, cutoff=0.80)
230
+ if _is_near_miss(name, c)][:1]
231
+ if close:
232
+ flip = "." if kind == "#" else "#"
233
+ return {"replacement": flip + close[0], "confidence": "similar",
234
+ "reason": f"closest live name is {flip}{close[0]}"}
235
+
236
+ return {"replacement": None, "confidence": "unresolved",
237
+ "reason": "no similar element found in any state fxcss could produce"}
238
+
239
+
240
+ def audit(session, theme: Path, verbose=True):
241
+ tokens = extract_tokens(theme)
242
+ if verbose:
243
+ print(f" {len(tokens)} distinct id/class tokens in the theme", flush=True)
244
+ print(" collecting live elements:", flush=True)
245
+ dom = collect_dom(session, verbose=verbose)
246
+
247
+ live = {"#" + i for i in dom["ids"]} | {"." + c for c in dom["classes"]}
248
+ findings = []
249
+ for token, uses in sorted(tokens.items()):
250
+ if token in live:
251
+ continue
252
+ info = suggest(token, dom)
253
+ info.update({"token": token, "uses": uses})
254
+ findings.append(info)
255
+
256
+ order = {"renamed": 0, "similar": 1, "unresolved": 2}
257
+ findings.sort(key=lambda f: (order[f["confidence"]], f["token"]))
258
+ return {"tokens": len(tokens), "live": len(live), "findings": findings}
259
+
260
+
261
+ def replace_in_line(text, token, replacement):
262
+ """Swap one id/class token in a line, leaving the rest of the rule alone."""
263
+ pattern = re.compile(r"(?<![\w-])" + re.escape(token) + r"(?![\w-])")
264
+ return pattern.sub(replacement, text)
265
+
266
+
267
+ BOLD, DIM, RED, GREEN, YELLOW, RESET = (
268
+ "\033[1m", "\033[2m", "\033[31m", "\033[32m", "\033[33m", "\033[0m")
269
+
270
+
271
+ def report(result, show_all=False, colour=True):
272
+ def c(code, s):
273
+ return f"{code}{s}{RESET}" if colour else s
274
+
275
+ actionable = [f for f in result["findings"] if f["confidence"] != "unresolved"]
276
+ unresolved = [f for f in result["findings"] if f["confidence"] == "unresolved"]
277
+
278
+ print()
279
+ if not actionable:
280
+ print(" No selectors need attention: every id and class the theme uses "
281
+ "was found\n in the running Firefox.")
282
+ else:
283
+ print(c(BOLD, f" {len(actionable)} selector"
284
+ f"{'s' if len(actionable) != 1 else ''} need attention"))
285
+
286
+ for finding in actionable:
287
+ label = "RENAMED" if finding["confidence"] == "renamed" else "SIMILAR"
288
+ tint = GREEN if finding["confidence"] == "renamed" else YELLOW
289
+ print()
290
+ print(f" {c(tint, label)} {c(BOLD, finding['token'])}"
291
+ f" → {c(BOLD, finding['replacement'])}")
292
+ print(f" {c(DIM, finding['reason'])}")
293
+ for use in finding["uses"][:3]:
294
+ after = replace_in_line(use["text"], finding["token"], finding["replacement"])
295
+ print()
296
+ print(f" {c(DIM, use['file'] + ':' + str(use['line']))}")
297
+ print(f" {c(RED, '- ' + use['text'].strip())}")
298
+ print(f" {c(GREEN, '+ ' + after.strip())}")
299
+ extra = len(finding["uses"]) - 3
300
+ if extra > 0:
301
+ plural = "s" if extra != 1 else ""
302
+ print()
303
+ print(f" {c(DIM, f'… and {extra} more occurrence{plural}')}")
304
+
305
+ if unresolved:
306
+ print()
307
+ print(f" {c(DIM, f'{len(unresolved)} other token(s) were not seen in any state fxcss could')}")
308
+ print(f" {c(DIM, 'produce. That usually means a platform-specific or state-specific')}")
309
+ print(f" {c(DIM, 'element rather than a removed one, so they are not counted above.')}")
310
+ if show_all:
311
+ for finding in unresolved:
312
+ first = finding["uses"][0]
313
+ print(f" {finding['token']:<44} {c(DIM, first['file'] + ':' + str(first['line']))}")
314
+ else:
315
+ print(f" {c(DIM, 'Pass --all to list them.')}")
316
+ print()
317
+
318
+
319
+ def write_patch(result, theme: Path, out: Path):
320
+ """Emit a unified diff of the confident replacements, for review."""
321
+ edits = {}
322
+ for finding in result["findings"]:
323
+ if finding["confidence"] != "renamed":
324
+ continue
325
+ for use in finding["uses"]:
326
+ edits.setdefault(use["file"], []).append(
327
+ (use["line"], finding["token"], finding["replacement"]))
328
+
329
+ if not edits:
330
+ return 0
331
+
332
+ chunks = []
333
+ for rel, changes in sorted(edits.items()):
334
+ path = theme / rel
335
+ original = path.read_text(encoding="utf-8", errors="replace").splitlines()
336
+ updated = list(original)
337
+ for line_no, token, replacement in changes:
338
+ index = line_no - 1
339
+ if 0 <= index < len(updated):
340
+ updated[index] = replace_in_line(updated[index], token, replacement)
341
+ chunks.extend(difflib.unified_diff(
342
+ original, updated, fromfile=f"a/{rel}", tofile=f"b/{rel}", lineterm=""))
343
+
344
+ out.write_text("\n".join(chunks) + "\n", encoding="utf-8")
345
+ return len(edits)
346
+
347
+
348
+ def changelog(before, after, theme_tokens=None):
349
+ """Diff two collected DOM snapshots."""
350
+ gone_ids = sorted(before["ids"] - after["ids"])
351
+ new_ids = sorted(after["ids"] - before["ids"])
352
+ gone_classes = sorted(before["classes"] - after["classes"])
353
+ new_classes = sorted(after["classes"] - before["classes"])
354
+
355
+ result = {
356
+ "removed": [f"#{i}" for i in gone_ids] + [f".{c}" for c in gone_classes],
357
+ "added": [f"#{i}" for i in new_ids] + [f".{c}" for c in new_classes],
358
+ }
359
+ if theme_tokens is not None:
360
+ result["affects_theme"] = sorted(set(result["removed"]) & set(theme_tokens))
361
+ return result
362
+
363
+
364
+ # --- unused code -----------------------------------------------------------
365
+
366
+ IMPORT_RE = re.compile(r"""@import\s+(?:url\(\s*)?["']?([^"')]+)["']?\s*\)?\s*;""")
367
+ PROP_DEF = re.compile(r"(--[\w-]+)\s*:")
368
+ PROP_USE = re.compile(r"var\(\s*(--[\w-]+)")
369
+
370
+ # Entry points Firefox loads by name. Anything not reachable from one of these
371
+ # is only reachable if something imports it.
372
+ ENTRY_SHEETS = ("userChrome.css", "userContent.css")
373
+
374
+ # Directories of deliberately opt-in sheets. A theme ships these expecting its
375
+ # installer (or the user) to enable them, so "nothing imports it" is the
376
+ # intended state rather than a finding.
377
+ OPTIONAL_DIRS = {"custom", "optional", "options", "extras", "variants"}
378
+
379
+
380
+ def import_graph(theme: Path):
381
+ """Every stylesheet reachable by following @import from the entry sheets."""
382
+ chrome = theme / "chrome"
383
+ reachable, queue = set(), []
384
+ for entry in ENTRY_SHEETS:
385
+ path = chrome / entry
386
+ if path.exists():
387
+ queue.append(path.resolve())
388
+
389
+ while queue:
390
+ current = queue.pop()
391
+ if current in reachable or not current.exists():
392
+ continue
393
+ reachable.add(current)
394
+ text = COMMENT.sub("", current.read_text(encoding="utf-8", errors="replace"))
395
+ for target in IMPORT_RE.findall(text):
396
+ target = target.strip()
397
+ if re.match(r"^(chrome:|resource:|https?:|data:)", target):
398
+ continue
399
+ queue.append((current.parent / target).resolve())
400
+ return reachable
401
+
402
+
403
+ def custom_properties(paths):
404
+ """Where each custom property is defined and where it is used."""
405
+ defined, used = {}, {}
406
+ for path in paths:
407
+ text = path.read_text(encoding="utf-8", errors="replace")
408
+ blanked = COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
409
+ for number, line in enumerate(blanked.splitlines(), 1):
410
+ for name in PROP_DEF.findall(line):
411
+ defined.setdefault(name, []).append((path, number))
412
+ for name in PROP_USE.findall(line):
413
+ used.setdefault(name, []).append((path, number))
414
+ return defined, used
415
+
416
+
417
+ PROBE_PROPERTIES = """
418
+ const [names] = arguments;
419
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
420
+ const doc = win.document;
421
+ const remaining = new Set(names);
422
+ const found = [];
423
+ // Custom properties can be declared on any element, not only :root, so probe
424
+ // the whole document. The remaining set shrinks as names are resolved, so this
425
+ // stays cheap in practice.
426
+ for (const el of [doc.documentElement, ...doc.querySelectorAll("*")]) {
427
+ if (!remaining.size) { break; }
428
+ const cs = win.getComputedStyle(el);
429
+ for (const name of [...remaining]) {
430
+ if (cs.getPropertyValue(name).trim() !== "") {
431
+ found.push(name);
432
+ remaining.delete(name);
433
+ }
434
+ }
435
+ }
436
+ return found;
437
+ """
438
+
439
+
440
+ def probe_properties(session, names):
441
+ """Which of these custom properties does this browser resolve to a value?"""
442
+ if not names:
443
+ return set()
444
+ return set(session.m.script(PROBE_PROPERTIES, [sorted(names)]))
445
+
446
+
447
+ def collect_unused(theme: Path):
448
+ """The parts that need no browser: reachability and property bookkeeping."""
449
+ chrome = theme / "chrome"
450
+ if not chrome.is_dir():
451
+ return None
452
+
453
+ reachable = import_graph(theme)
454
+ orphans, optional = [], []
455
+ for sheet in sorted(chrome.rglob("*.css")):
456
+ if sheet.resolve() in reachable:
457
+ continue
458
+ if OPTIONAL_DIRS & {p.name for p in sheet.relative_to(chrome).parents}:
459
+ optional.append(sheet)
460
+ else:
461
+ orphans.append(sheet)
462
+
463
+ defined, used = custom_properties(sorted(reachable))
464
+ return {
465
+ "theme": theme,
466
+ "reachable": len(reachable),
467
+ "orphans": [p.relative_to(theme) for p in orphans],
468
+ "optional": [p.relative_to(theme) for p in optional],
469
+ "defined": defined,
470
+ "used": used,
471
+ }
472
+
473
+
474
+ def classify_unused(static, firefox_knows):
475
+ """Separate real dead code from names Firefox itself reads or provides.
476
+
477
+ Both directions need the browser's opinion, and asking an *unthemed*
478
+ Firefox is what makes the answer meaningful:
479
+
480
+ * A property used but not defined here may still be one Firefox provides --
481
+ `--toolbarbutton-inner-padding` is Firefox's, not the theme's.
482
+ * A property defined here but never read here is usually the whole point:
483
+ setting `--arrowpanel-background` exists precisely so Firefox's own rules
484
+ pick it up. Only a name Firefox has never heard of is dead.
485
+ """
486
+ if static is None:
487
+ return None
488
+ theme = static["theme"]
489
+ defined, used = static["defined"], static["used"]
490
+
491
+ missing = [name for name in sorted(set(used) - set(defined))
492
+ if name not in firefox_knows]
493
+ dead = [name for name in sorted(set(defined) - set(used))
494
+ if name not in firefox_knows]
495
+ overrides = sorted((set(defined) - set(used)) & firefox_knows)
496
+
497
+ return {
498
+ "reachable": static["reachable"],
499
+ "orphans": static["orphans"],
500
+ "optional": static["optional"],
501
+ "overrides": len(overrides),
502
+ "unused_properties": [
503
+ {"name": name,
504
+ "file": str(defined[name][0][0].relative_to(theme)),
505
+ "line": defined[name][0][1]}
506
+ for name in dead],
507
+ "missing_properties": [
508
+ {"name": name,
509
+ "file": str(used[name][0][0].relative_to(theme)),
510
+ "line": used[name][0][1],
511
+ "uses": len(used[name])}
512
+ for name in missing],
513
+ }
514
+
515
+
516
+ def report_unused(unused, colour=True, show_all=False):
517
+ def c(code, s):
518
+ return f"{code}{s}{RESET}" if colour else s
519
+
520
+ if not unused:
521
+ return
522
+ total = (len(unused["orphans"]) + len(unused["unused_properties"])
523
+ + len(unused["missing_properties"]))
524
+ print(c(BOLD, " Unused and unreachable"))
525
+ print(f" {c(DIM, 'Housekeeping, not breakage — nothing here stops the theme working.')}")
526
+ print()
527
+
528
+ if unused["orphans"]:
529
+ print(f" {c(YELLOW, 'NOT IMPORTED')} {len(unused['orphans'])} stylesheet"
530
+ f"{'s' if len(unused['orphans']) != 1 else ''} nothing reaches")
531
+ for path in unused["orphans"][:12]:
532
+ print(f" {path}")
533
+ extra = len(unused["orphans"]) - 12
534
+ if extra > 0:
535
+ print(" " + c(DIM, f"… and {extra} more"))
536
+ print()
537
+
538
+ if unused["missing_properties"]:
539
+ print(f" {c(RED, 'UNDEFINED')} {len(unused['missing_properties'])} custom "
540
+ f"propert{'ies' if len(unused['missing_properties']) != 1 else 'y'} used "
541
+ f"but never set")
542
+ print(f" {c(DIM, 'Firefox does not provide these either, so the var() falls back or fails.')}")
543
+ for item in unused["missing_properties"][:10]:
544
+ where = f"{item['file']}:{item['line']}"
545
+ print(f" {item['name']:<44} {c(DIM, where)} {c(DIM, '×' + str(item['uses']))}")
546
+ print()
547
+
548
+ if unused.get("overrides"):
549
+ count = unused["overrides"]
550
+ noun = "property is" if count == 1 else "properties are"
551
+ print(" " + c(DIM, f"{count} more {noun} set here but read only by Firefox —"))
552
+ print(" " + c(DIM, "deliberate overrides, not dead code."))
553
+ print()
554
+
555
+ if unused["unused_properties"]:
556
+ print(f" {c(DIM, 'DEFINED ONLY')} {len(unused['unused_properties'])} custom "
557
+ f"propert{'ies' if len(unused['unused_properties']) != 1 else 'y'} set here "
558
+ f"and read nowhere in the theme")
559
+ print(" " + c(DIM, "An unthemed Firefox does not resolve these names either, so they are"))
560
+ print(" " + c(DIM, "likely renamed or dropped. Worth checking rather than deleting: a"))
561
+ print(" " + c(DIM, "name Firefox references without setting would look the same here."))
562
+ limit = None if show_all else 10
563
+ for item in unused["unused_properties"][:limit]:
564
+ print(f" {item['name']:<44} {c(DIM, item['file'] + ':' + str(item['line']))}")
565
+ if limit and len(unused["unused_properties"]) > limit:
566
+ print(f" {c(DIM, 'Pass --all to list them.')}")
567
+ print()
568
+
569
+ if total == 0:
570
+ print(f" {c(DIM, 'Nothing unused found.')}\n")
571
+
572
+
573
+ # --- snapshots -------------------------------------------------------------
574
+
575
+ def make_snapshot(session, verbose=False):
576
+ """A record of every chrome name this Firefox has, plus its version.
577
+
578
+ Committing one of these lets a scheduled job answer "what did the new
579
+ Firefox change" without keeping an old browser around to compare against.
580
+ """
581
+ info = session.info()
582
+ dom = collect_dom(session, verbose=verbose)
583
+ return {
584
+ "version": info["version"],
585
+ "buildID": info["buildID"],
586
+ "os": info["os"],
587
+ "ids": sorted(dom["ids"]),
588
+ "classes": sorted(dom["classes"]),
589
+ }
590
+
591
+
592
+ def load_snapshot(path: Path):
593
+ import json
594
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
595
+ return data, {"ids": set(data["ids"]), "classes": set(data["classes"])}