devicectl-core 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.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,935 @@
1
+ #!/usr/bin/env python3
2
+ """The checks the web UI needs that no off-the-shelf linter does.
3
+
4
+ Biome (``biome.json``) parses and lints the browser half: syntax, the
5
+ recommended rule set, CSS, and the one page. It is better at all of that
6
+ than anything hand-written here could be. What it does not do -- what no
7
+ JavaScript linter does, because it is normally a bundler's job -- is look
8
+ across the files:
9
+
10
+ * **Wiring.** Every ``import`` has to name a file that exists and a name
11
+ that file exports, and every ``export`` has to be imported by somebody.
12
+ There is no build step here: the modules are served to the browser as
13
+ they are on disk and only meet each other at run time, so a mistyped
14
+ path or export name is a blank page found by reloading, and not before.
15
+ * **What a card is allowed to claim.** A full-width card ends its row, so
16
+ one that spans the grid without a row's worth of content stacks the
17
+ whole tab underneath it. The stylesheet says which content earns it;
18
+ nothing but a reader was checking.
19
+ * **Operation names.** A progress bar draws only while the operation the
20
+ server published matches the name the panel is waiting for -- one string
21
+ written in ``web/api.py`` and again, in another language, in a
22
+ ``what=`` prop. Nothing else can see both halves, so a rename turns a
23
+ bar off silently and no test fails.
24
+ * **The runtime's names.** A module that calls ``useState`` or tags a
25
+ template with ``html`` without importing it from the vendored module is
26
+ a component that renders nothing -- the import is a run-time question,
27
+ so no bundler ever gets to answer it. The names the vendor exports
28
+ are therefore checked like the wiring: used means imported.
29
+ * **Components drawn into a template.** The same fault, one level up and
30
+ invisible to the check above, which reads the code with its templates
31
+ blanked out: ``html`<${Band} ...>``` with no ``Band`` in scope throws
32
+ while the card is being built and takes the tab with it, leaving the
33
+ page showing whatever was on it before. Every other check passes --
34
+ the file is valid JavaScript, the template parses, the class names all
35
+ exist -- so this one asks the one question left: is it in scope.
36
+
37
+ That is the whole of it, and it is meant to stay that way: anything a
38
+ general JavaScript or CSS linter can check belongs in ``biome.json``, not
39
+ here. Run it over a program's own UI::
40
+
41
+ python -m devicectl.devtools.frontlint src/<program>/web/static
42
+
43
+ or let that program's ``pytest`` do it; either way a problem is one line of
44
+ ``path:line: CODE message``.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import re
50
+ import sys
51
+ from dataclasses import dataclass
52
+ from pathlib import Path
53
+
54
+ # The module every other one is reachable from; its exports answer to the
55
+ # page, not to an import.
56
+ ENTRY_MODULE = "app.js"
57
+
58
+ # Files under here are somebody else's and are checked by nobody.
59
+ VENDOR = "vendor"
60
+
61
+ # The URL prefix the server reserves for the shared frontend, and which a
62
+ # program's own modules import the shared ones by. It is an absolute URL
63
+ # rather than a relative path because the two trees are two directories on
64
+ # two sides of a package boundary and only meet at the server: `../` out of
65
+ # a program's static root would name whatever happens to sit beside it.
66
+ CORE_PREFIX = "/core/"
67
+
68
+ # Written on a rule whose class is only ever assembled at run time -- a log
69
+ # level, a job state -- which this checker has no way of seeing.
70
+ KEEP = "frontlint: keep"
71
+
72
+ IMPORT_NAMED = re.compile(r"import\s*\{([^}]*)\}\s*from\s*['\"]([^'\"]+)['\"]")
73
+ IMPORT_STAR = re.compile(r"import\s*\*\s*as\s*(\w+)\s*from\s*['\"]([^'\"]+)['\"]")
74
+ IMPORT_ANY = re.compile(r"^\s*import\b[^;]*from\s*['\"]([^'\"]+)['\"]", re.M)
75
+ IMPORT_NAMES = re.compile(r"import\s*\{([^}]*)\}", re.S)
76
+ EXPORTED = re.compile(
77
+ r"^export\s+(?:async\s+)?(?:function|const|let|var|class)\s+(\w+)", re.M
78
+ )
79
+ HTML_ASSET = re.compile(r"(?:src|href)=\"([^\"]+)\"")
80
+
81
+ # A tab icon written into the page itself. See :func:`_check_page_icons`.
82
+ HTML_ICON = re.compile(r"<link\b[^>]*\brel=\"icon\"[^>]*>", re.I | re.S)
83
+
84
+
85
+ # What a module can take from the vendored preact-htm bundle. A name used
86
+ # without its import is a ReferenceError that kills the component mid-render,
87
+ # so the page keeps whatever it last showed -- found, if at all, by hand.
88
+
89
+ VENDOR_EXPORT = re.compile(r"export\{([^}]*)\}")
90
+ VENDOR_NAME = re.compile(r"([a-zA-Z_$][\w$]*)\s+as\s+([a-zA-Z_$][\w$]*)")
91
+ # The shapes that mean "this identifier really is the runtime's": a hook or
92
+ # factory call, a tagged template, a component base class. A bare mention
93
+ # (a comment, an object key) is not. The scan runs over the code with its
94
+ # string and template interiors blanked out, so an ``h`` at the end of a
95
+ # literal and an ``html`` in a comment are never in these shapes at all.
96
+ RUNTIME_USE = {
97
+ "call": re.compile(r"(?<![\w$.])(\w+)\s*\("),
98
+ "tag": re.compile(r"(?<![\w$.])(\w+)\s*`"),
99
+ "extends": re.compile(r"\bextends\s+(\w+)"),
100
+ }
101
+
102
+ # A component interpolated into an `html` template: `<${Card}` and its
103
+ # closing `<//>`. This is the one way a module reaches another module's
104
+ # code that the scan above cannot see, because it happens *inside* a
105
+ # template literal and that scan blanks template interiors out. A name
106
+ # written into a `class=` or a piece of prose is not this shape.
107
+ COMPONENT_USE = re.compile(r"<\$\{\s*([A-Za-z_$][\w$]*)\s*\}")
108
+
109
+
110
+ class _Scan:
111
+ """A JavaScript module being read for the code in it.
112
+
113
+ What is wanted is the names a module reaches for at runtime, with
114
+ nothing that is merely written about them: no comments, no strings, and
115
+ none of the prose inside a template. Length and newlines are preserved,
116
+ so an offset into the result is an offset into the original and line
117
+ numbers survive.
118
+
119
+ This was two regular expressions -- one for comments, one for a string
120
+ in any of its three quotes -- applied one after the other, and it could
121
+ not be. A template literal holds code inside ``${...}``, that code
122
+ holds templates of its own, and an expression that matches from a
123
+ backtick to the next backtick cannot tell a nested template's opening
124
+ quote from its own closing one. So the file was read as an alternating
125
+ band of "string" and "code" that had little to do with where the strings
126
+ were: the pairing came out right or wrong by the parity of how many
127
+ backticks happened to be above it, and prose that landed in a "code"
128
+ band was read as source. A page was reported for using a name that
129
+ appears only in a sentence about it, and moving an unrelated tag five
130
+ hundred lines earlier is what made it appear.
131
+
132
+ The comments went the same way: ``<//>`` is htm's closing tag, on a
133
+ hundred lines of both pages, and blanking it as the start of a comment
134
+ took the rest of the line with it -- including the backtick that closed
135
+ the template it was in.
136
+
137
+ So this walks the text, which is the only thing that can answer the
138
+ question.
139
+ """
140
+
141
+ def __init__(self, text: str) -> None:
142
+ self.text = text
143
+ self.out = list(text)
144
+ self.size = len(text)
145
+
146
+ def blanked(self) -> str:
147
+ """Read the whole module and return it with its prose taken out."""
148
+ self.code(0, nested=False)
149
+ return "".join(self.out)
150
+
151
+ def blank(self, start: int, stop: int) -> None:
152
+ """Replace a span with spaces, leaving its newlines where they are."""
153
+ for index in range(start, stop):
154
+ if self.out[index] != "\n":
155
+ self.out[index] = " "
156
+
157
+ def comment(self, at: int) -> int:
158
+ """Read a // or /* comment; return where it ends."""
159
+ if self.text[at : at + 2] == "//":
160
+ stop = self.text.find("\n", at)
161
+ stop = self.size if stop < 0 else stop
162
+ else:
163
+ stop = self.text.find("*/", at + 2)
164
+ stop = self.size if stop < 0 else stop + 2
165
+ self.blank(at, stop)
166
+ return stop
167
+
168
+ def quoted(self, at: int) -> int:
169
+ """Read a ' or " string; return where it ends.
170
+
171
+ An opening quote with no closing one before the end of the line is
172
+ an apostrophe in prose -- "the pack's contactors" -- which is only
173
+ reached at all inside a ``${...}``. It is left where it is, rather
174
+ than swallowing the rest of the file.
175
+ """
176
+ quote = self.text[at]
177
+ index = at + 1
178
+ while index < self.size and self.text[index] not in (quote, "\n"):
179
+ index += 2 if self.text[index] == "\\" else 1
180
+ if index >= self.size or self.text[index] != quote:
181
+ return at + 1
182
+ self.blank(at + 1, index)
183
+ return index + 1
184
+
185
+ def template(self, at: int) -> int:
186
+ """Read a `template`, recursing into every ${...} it holds."""
187
+ index = at + 1
188
+ while index < self.size:
189
+ here = self.text[index]
190
+ if here == "\\":
191
+ self.blank(index, min(index + 2, self.size))
192
+ index += 2
193
+ elif here == "`":
194
+ return index + 1
195
+ elif self.text[index : index + 2] == "${":
196
+ index = self.code(index + 2, nested=True)
197
+ else:
198
+ self.blank(index, index + 1)
199
+ index += 1
200
+ return index
201
+
202
+ def code(self, at: int, nested: bool) -> int:
203
+ """Read ordinary code; when nested, stop after its closing brace."""
204
+ index, depth = at, 0
205
+ while index < self.size:
206
+ here = self.text[index]
207
+ if self.text[index : index + 2] in ("//", "/*"):
208
+ index = self.comment(index)
209
+ elif here in "\"'":
210
+ index = self.quoted(index)
211
+ elif here == "`":
212
+ index = self.template(index)
213
+ else:
214
+ if here == "{":
215
+ depth += 1
216
+ elif here == "}":
217
+ if nested and depth == 0:
218
+ return index + 1
219
+ depth -= 1
220
+ index += 1
221
+ return index
222
+
223
+
224
+ def _blank_strings_and_comments(text: str) -> str:
225
+ """Return the module with every string, template and comment blanked."""
226
+ return _Scan(text).blanked()
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class Problem:
231
+ """One thing wrong, at one place, in the words a reader needs."""
232
+
233
+ path: Path
234
+ line: int
235
+ code: str
236
+ message: str
237
+
238
+ def render(self, root: Path) -> str:
239
+ """Format as an editor-clickable line."""
240
+ try:
241
+ where = self.path.relative_to(root)
242
+ except ValueError: # pragma: no cover - only if given an outside path
243
+ where = self.path
244
+ return f"{where}:{self.line}: {self.code} {self.message}"
245
+
246
+
247
+ def _line_of(text: str, index: int) -> int:
248
+ """Return the 1-based line number of a character offset."""
249
+ return text.count("\n", 0, index) + 1
250
+
251
+
252
+ def _blank_css(text: str) -> str:
253
+ """Return the stylesheet with its comments replaced by spaces."""
254
+ out = list(text)
255
+ for match in re.finditer(r"/\*.*?\*/", text, re.S):
256
+ for index in range(*match.span()):
257
+ if out[index] != "\n":
258
+ out[index] = " "
259
+ return "".join(out)
260
+
261
+
262
+ def css_classes(text: str) -> dict[str, int]:
263
+ """Return every class the stylesheet defines, with the line it is on."""
264
+ blanked = _blank_css(text)
265
+ classes: dict[str, int] = {}
266
+ for match in re.finditer(r"\.(-?[_a-zA-Z][\w-]*)", blanked):
267
+ # A class in a selector, not `0.5` or a file extension in a url().
268
+ classes.setdefault(match.group(1), _line_of(text, match.start()))
269
+ return classes
270
+
271
+
272
+ def resolve_import(path: Path, spec: str, core: Path | None) -> Path | None:
273
+ """Return the file an import specifier names, or None if it cannot.
274
+
275
+ Two kinds of specifier reach a browser here. A relative one is a path
276
+ from the importing module, as everywhere else. One beginning
277
+ :data:`CORE_PREFIX` is a URL the server answers from the shared
278
+ package's own static tree, which lives in another distribution
279
+ entirely -- so it can only be resolved by someone who was told where
280
+ that tree is, and the answer is None when nobody was.
281
+ """
282
+ if spec.startswith(CORE_PREFIX):
283
+ if core is None:
284
+ return None
285
+ return (core / spec[len(CORE_PREFIX) :]).resolve()
286
+ return (path.parent / spec).resolve()
287
+
288
+
289
+ def _check_page_assets(
290
+ pages: list[Path], sources: dict[Path, str], core: Path | None
291
+ ) -> list[Problem]:
292
+ """Check that every asset a page links to is a file that is there."""
293
+ found: list[Problem] = []
294
+ for path in pages:
295
+ for match in HTML_ASSET.finditer(sources[path]):
296
+ target = match.group(1)
297
+ if target.startswith(("http:", "https:", "data:", "#", "//")):
298
+ continue
299
+ named = resolve_import(path, target, core)
300
+ if named is None:
301
+ named = path.parent / target.lstrip("/")
302
+ if not named.is_file():
303
+ line = _line_of(sources[path], match.start())
304
+ found.append(Problem(path, line, "H001", f"no such file: {target}"))
305
+ return found
306
+
307
+
308
+ def _check_page_icons(pages: list[Path], sources: dict[Path, str]) -> list[Problem]:
309
+ """Report a tab icon drawn in the page rather than taken from the mark.
310
+
311
+ A program has one mark and wears it in two places: in front of its
312
+ wordmark, and in the browser's tab. Both come from one list of shapes,
313
+ rendered by ``Glyph`` and serialised by ``useFavicon`` -- see js/shell.js.
314
+
315
+ This is the rule that was broken. Both programs had a second drawing
316
+ percent-encoded into a ``data:`` URI here: unreadable, unreachable from
317
+ anything, and never going to be edited when the header's mark changed.
318
+ One of them ended up a battery of different proportions charged to a
319
+ different level, the other a bolt of a different shape, and nothing could
320
+ say so except opening the two side by side. An icon with a drawing in it
321
+ is the only shape that failure can take, so it is the thing to refuse.
322
+ """
323
+ found: list[Problem] = []
324
+ for path in pages:
325
+ for match in HTML_ICON.finditer(sources[path]):
326
+ target = HTML_ASSET.search(match.group(0))
327
+ href = target.group(1) if target else ""
328
+ # `data:,` is the empty placeholder a page keeps so the browser
329
+ # does not go asking for a /favicon.ico while the module loads.
330
+ if not href.startswith("data:") or href == "data:,":
331
+ continue
332
+ found.append(
333
+ Problem(
334
+ path,
335
+ _line_of(sources[path], match.start()),
336
+ "H003",
337
+ "a tab icon drawn here rather than from the program's mark; "
338
+ "declare the mark once and stamp it with useFavicon",
339
+ )
340
+ )
341
+ return found
342
+
343
+
344
+ def _name_origins(
345
+ vendor_module: Path | None, modules: list[Path], sources: dict[Path, str]
346
+ ) -> dict[str, str]:
347
+ """Map every name a page can use to the module that binds it."""
348
+ origins: dict[str, str] = {}
349
+ if vendor_module is not None:
350
+ origins.update(dict.fromkeys(vendor_names(vendor_module), vendor_module.name))
351
+ for module in modules:
352
+ for name in EXPORTED.findall(sources[module]):
353
+ origins.setdefault(name, module.name)
354
+ return origins
355
+
356
+
357
+ def check_tree(
358
+ root: Path, *, core: Path | None = None, library: bool = False
359
+ ) -> list[Problem]:
360
+ """Check the whole static tree: the wiring, the style, the page.
361
+
362
+ ``core`` names the shared package's static tree, whose modules this one
363
+ imports through :data:`CORE_PREFIX`. Both trees are read: the shared
364
+ modules' exports are what a program's imports are checked against, and
365
+ the two stylesheets are one stylesheet as far as the browser is
366
+ concerned, so a class either of them defines answers for markup in
367
+ either of them.
368
+
369
+ ``library`` says the tree being checked *is* the shared one, with no
370
+ program around it. Two checks are then not answerable and are left
371
+ out: an export the shared package's own modules never import is the
372
+ normal case for a library, and so is a rule in ``core.css`` that only a
373
+ program's markup wears. Both are checked when a program is linted with
374
+ ``core=`` pointing here.
375
+ """
376
+ # Absolute from here down: an import resolves to an absolute path, and
377
+ # the two are keys into the same dictionaries. A relative root -- which
378
+ # is what a command line hands over -- would key every module twice and
379
+ # quietly answer "nobody imports this" about all of them.
380
+ root = root.resolve()
381
+ # In library mode the tree being checked *is* the shared one, so it is
382
+ # its own `/core/`: the modules in it reach each other by the prefix a
383
+ # browser will serve them under, not by a relative path that only works
384
+ # before the package is installed.
385
+ core = core.resolve() if core is not None else (root if library else None)
386
+ # In library mode the two are one tree, and reading it twice would
387
+ # report everything in it twice.
388
+ shared = core if core is not None and core != root else None
389
+
390
+ def own(pattern: str) -> list[Path]:
391
+ """Find the program's own files of one kind.
392
+
393
+ Not the vendored runtime's, and not the shared tree's if that
394
+ happens to sit inside this one.
395
+ """
396
+ return sorted(
397
+ p
398
+ for p in root.rglob(pattern)
399
+ if VENDOR not in p.parts and not (shared and shared in p.parents)
400
+ )
401
+
402
+ scripts = own("*.js")
403
+ styles = own("*.css")
404
+ pages = own("*.html")
405
+ core_scripts = (
406
+ sorted(p for p in shared.rglob("*.js") if VENDOR not in p.parts)
407
+ if shared is not None
408
+ else []
409
+ )
410
+ core_styles = (
411
+ sorted(p for p in shared.rglob("*.css") if VENDOR not in p.parts)
412
+ if shared is not None
413
+ else []
414
+ )
415
+ every = [*scripts, *styles, *pages, *core_scripts, *core_styles]
416
+ sources = {path: path.read_text(encoding="utf-8") for path in every}
417
+
418
+ found = _check_page_assets(pages, sources, core)
419
+ found += _check_page_icons(pages, sources)
420
+
421
+ # The one vendored module the UI's own code imports by name. There is
422
+ # exactly one on a page -- a second copy of Preact is a second set of
423
+ # hooks, and a component from one of them rendered inside the other
424
+ # throws on its first `useState` -- so a program that imports the
425
+ # shared frontend imports the shared runtime with it, and its own tree
426
+ # has no `vendor/` at all. Whichever tree carries it answers for both.
427
+ vendor_module = next(root.glob(f"{VENDOR}/*.js"), None)
428
+ if vendor_module is None and shared is not None:
429
+ vendor_module = next(shared.glob(f"{VENDOR}/*.js"), None)
430
+
431
+ # Where every name on this page comes from: the runtime's, and every
432
+ # module's own exports. A name used without the import that binds it
433
+ # is a ReferenceError the moment that line runs, and nothing else here
434
+ # -- not Biome, which cannot see across files without a bundler --
435
+ # answers for it.
436
+ origins = _name_origins(vendor_module, [*scripts, *core_scripts], sources)
437
+ found += check_known_names([*scripts, *core_scripts], sources, origins)
438
+ found += check_components([*scripts, *core_scripts], sources)
439
+
440
+ found += check_wiring(scripts, core_scripts, sources, core, library=library)
441
+ users = [*scripts, *core_scripts, *pages]
442
+ # A rule in the shared stylesheet is answerable only by every program
443
+ # that uses it at once, which no single run can see: one of them not
444
+ # wearing `.toolbar` says nothing about the other. So C002 asks about a
445
+ # program's own stylesheet only -- and about nothing at all in library
446
+ # mode, where there is no program. C003 is the other way round: the two
447
+ # sheets are one sheet as far as the browser is concerned, so a class
448
+ # either of them defines answers for markup in either of them.
449
+ if not library:
450
+ found += check_style_use(styles, sources, users)
451
+ found += check_style_defined([*styles, *core_styles], sources, users)
452
+ found += check_card_widths([*scripts, *core_scripts], sources)
453
+ # The server half of the page, one directory up from its static files.
454
+ api = root.parent / "api.py"
455
+ if api.is_file():
456
+ found += check_operation_names(scripts, sources, api)
457
+
458
+ return sorted(found, key=lambda p: (str(p.path), p.line, p.code))
459
+
460
+
461
+ def vendor_names(vendor_module: Path) -> set[str]:
462
+ """Return the names the vendored module exports, from its bundle."""
463
+ names: set[str] = set()
464
+ for exported in VENDOR_EXPORT.findall(vendor_module.read_text(encoding="utf-8")):
465
+ names.update(name for _, name in VENDOR_NAME.findall(exported))
466
+ return names
467
+
468
+
469
+ def check_known_names(
470
+ scripts: list[Path], sources: dict[Path, str], origins: dict[str, str]
471
+ ) -> list[Problem]:
472
+ """Report a name used without the import that binds it.
473
+
474
+ A module that calls ``useState``, tags with ``html`` or calls
475
+ ``panelWait`` relies on another module at run time. The import that
476
+ binds them is ordinary JavaScript, so a bundler would catch its
477
+ absence; there is no bundler here, and the failure mode is the worst
478
+ kind of quiet -- the component throws mid-render and the page keeps
479
+ the tab that was open before it, so the tree looks fine and one card
480
+ in it is simply not there.
481
+
482
+ ``origins`` maps every name anything on this page exports -- the
483
+ vendored runtime's, and every module's own -- to where it comes from.
484
+ Only names on that list are answerable: a free variable this checker
485
+ has never heard of is somebody else's business.
486
+
487
+ Generous by design on both sides. A name counts as bound if it
488
+ arrives by any route -- a named import from anywhere, a star import,
489
+ or a local ``const``/``function`` of its own -- and as used only in
490
+ the shapes that really reach the runtime: a call, a tagged template,
491
+ or a base class after ``extends``. A name in a comment or an object
492
+ key is neither, and stays silent. One report per name, at its first
493
+ use.
494
+ """
495
+ found: list[Problem] = []
496
+ for path in scripts:
497
+ text = sources[path]
498
+ bound = _bound_names(text)
499
+ # The code with its strings, templates and comments blanked: a
500
+ # mention in prose or inside a literal never reaches the runtime.
501
+ code = _blank_strings_and_comments(text)
502
+ reported: set[str] = set()
503
+ for pattern in RUNTIME_USE.values():
504
+ for match in pattern.finditer(code):
505
+ name = match.group(1)
506
+ if name not in origins or name in bound or name in reported:
507
+ continue
508
+ reported.add(name)
509
+ found.append(
510
+ Problem(
511
+ path,
512
+ _line_of(text, match.start()),
513
+ "J006",
514
+ f"{name} is used but not imported from {origins[name]}",
515
+ )
516
+ )
517
+ return found
518
+
519
+
520
+ def _bound_names(text: str) -> set[str]:
521
+ """Every name this module has, by any route.
522
+
523
+ Generous by design: a named import from anywhere, a star import, a
524
+ local declaration, a destructured binding, a parameter. Over-counting
525
+ here only means a check stays quiet; under-counting would mean it
526
+ accused a module of not having what it plainly has.
527
+ """
528
+ bound: set[str] = set()
529
+ for names in IMPORT_NAMES.findall(text):
530
+ bound.update(
531
+ name.strip().split(" as ")[0] for name in names.split(",") if name.strip()
532
+ )
533
+ # Destructured bindings and parameter names: `[a, b]`, `{k: v}`,
534
+ # `(a, b) =>`. A parameter shadows an imported name harmlessly.
535
+ #
536
+ # Never after a `$`, which is the one shape that looks exactly like a
537
+ # destructured binding and is not one: `<${Card}` -- a brace, a word, a
538
+ # brace -- is a component being *used*, and counting it as a binding
539
+ # would have every template introduce its own components.
540
+ # The closing mark is looked at, not taken: taken, the comma after one
541
+ # name was the comma the next one needed in front of it, and every
542
+ # second name in `{ a, b, c, d }` went uncounted.
543
+ bound.update(re.findall(r"(?<!\$)[([{,]\s*(\w+)\s*(?=[,)\]}=:])", text))
544
+ bound.update(re.findall(r"\b(?:const|let|var|function|class)\s+(\w+)", text))
545
+ bound.update(re.findall(r"\b(\w+)\s+as\s+\w+", text))
546
+ return bound
547
+
548
+
549
+ def check_components(scripts: list[Path], sources: dict[Path, str]) -> list[Problem]:
550
+ """Report a component drawn into a template that the module has not got.
551
+
552
+ ``html`<${Band} .../>``` with no ``Band`` in scope is a ReferenceError
553
+ thrown while the card is being built, which takes the whole tab with
554
+ it: the page shows whatever was on screen before, nothing says why,
555
+ and every other check on this page passes. :func:`check_known_names`
556
+ cannot see it -- it reads the code with template interiors blanked,
557
+ which is where every one of these lives.
558
+
559
+ Unlike that check, this one does not need to know where a name should
560
+ have come from. A component is either in scope or it is not, and
561
+ anything in this shape that is not in scope is wrong however it was
562
+ meant to be bound.
563
+
564
+ """
565
+ found: list[Problem] = []
566
+ for path in scripts:
567
+ text = sources[path]
568
+ bound = _bound_names(text)
569
+ reported: set[str] = set()
570
+ for match in COMPONENT_USE.finditer(text):
571
+ name = match.group(1)
572
+ if name in bound or name in reported:
573
+ continue
574
+ reported.add(name)
575
+ found.append(
576
+ Problem(
577
+ path,
578
+ _line_of(text, match.start()),
579
+ "J007",
580
+ f"<${{{name}}} is drawn here and {name} is not in scope",
581
+ )
582
+ )
583
+ return found
584
+
585
+
586
+ def check_wiring(
587
+ scripts: list[Path],
588
+ core_scripts: list[Path],
589
+ sources: dict[Path, str],
590
+ core: Path | None,
591
+ *,
592
+ library: bool = False,
593
+ ) -> list[Problem]:
594
+ """Check that the modules can actually find each other in a browser.
595
+
596
+ The shared package's modules are read alongside the program's own, so
597
+ an import through :data:`CORE_PREFIX` is held to the same two
598
+ questions as any other: the file has to be there, and it has to export
599
+ the name. Only the program's own exports are held to being imported --
600
+ a shared module exports for two programs and is complete when neither
601
+ of them is looking.
602
+ """
603
+ found: list[Problem] = []
604
+ every = [*scripts, *core_scripts]
605
+ exported = {path: set(EXPORTED.findall(sources[path])) for path in every}
606
+ imported: dict[Path, set[str]] = {path: set() for path in every}
607
+
608
+ for path in every:
609
+ found.extend(_check_imports(path, sources[path], exported, imported, core))
610
+ if not library:
611
+ found.extend(_check_unused_exports(scripts, sources, exported, imported))
612
+ return found
613
+
614
+
615
+ def _check_imports(
616
+ path: Path,
617
+ text: str,
618
+ exported: dict[Path, set[str]],
619
+ imported: dict[Path, set[str]],
620
+ core: Path | None,
621
+ ) -> list[Problem]:
622
+ """Check one module's imports, and note what it took from each target."""
623
+ found: list[Problem] = []
624
+ for match in IMPORT_ANY.finditer(text):
625
+ spec = match.group(1)
626
+ target = resolve_import(path, spec, core)
627
+ if target is not None and target.is_file():
628
+ continue
629
+ said = (
630
+ f"no such module: {spec}"
631
+ if target is not None
632
+ else f"{spec} needs the shared static tree, and none was named"
633
+ )
634
+ found.append(Problem(path, _line_of(text, match.start()), "J003", said))
635
+ for match in IMPORT_NAMED.finditer(text):
636
+ target = resolve_import(path, match.group(2), core)
637
+ if target not in exported:
638
+ continue # vendored or missing; reported above if missing
639
+ for raw in match.group(1).split(","):
640
+ name = raw.split(" as ")[0].strip()
641
+ if not name:
642
+ continue
643
+ imported[target].add(name)
644
+ if name not in exported[target]:
645
+ found.append(
646
+ Problem(
647
+ path,
648
+ _line_of(text, match.start()),
649
+ "J004",
650
+ f"{match.group(2)} does not export {name}",
651
+ )
652
+ )
653
+ for match in IMPORT_STAR.finditer(text):
654
+ target = resolve_import(path, match.group(2), core)
655
+ if target in exported:
656
+ imported[target].update(exported[target])
657
+ return found
658
+
659
+
660
+ def _check_unused_exports(
661
+ scripts: list[Path],
662
+ sources: dict[Path, str],
663
+ exported: dict[Path, set[str]],
664
+ imported: dict[Path, set[str]],
665
+ ) -> list[Problem]:
666
+ """Report a name a program's own module exports that nothing imports."""
667
+ found: list[Problem] = []
668
+ for path in scripts:
669
+ if path.name == ENTRY_MODULE:
670
+ continue
671
+ for name in sorted(exported[path] - imported[path]):
672
+ match = re.search(
673
+ rf"^export\s+\S+\s+{re.escape(name)}\b", sources[path], re.M
674
+ )
675
+ line = _line_of(sources[path], match.start()) if match else 1
676
+ found.append(
677
+ Problem(path, line, "J005", f"{name} is exported but never imported")
678
+ )
679
+ return found
680
+
681
+
682
+ def check_style_use(
683
+ styles: list[Path], sources: dict[Path, str], users: list[Path]
684
+ ) -> list[Problem]:
685
+ """Report CSS classes that the JavaScript and the page never mention.
686
+
687
+ Deliberately generous: a class counts as used if its name appears
688
+ anywhere in a module or the page, because half of them are assembled at
689
+ run time (``` `pill ${state}` ```) and a checker that guessed at those
690
+ would cry wolf. What is left over is a rule for an element that is gone.
691
+ """
692
+ text = "\n".join(sources[path] for path in users)
693
+ found: list[Problem] = []
694
+ for path in styles:
695
+ lines = sources[path].splitlines()
696
+ for name, line in sorted(css_classes(sources[path]).items()):
697
+ if KEEP in lines[line - 1]:
698
+ continue
699
+ if not re.search(rf"(?<![\w-]){re.escape(name)}(?![\w-])", text):
700
+ found.append(Problem(path, line, "C002", f".{name} is used by nothing"))
701
+ return found
702
+
703
+
704
+ # A `class="a b c"` written straight into a template, with nothing
705
+ # interpolated into it. Those are the ones a stylesheet can be held to:
706
+ # `class=${`pill ${state}`}` is assembled at run time and is not this
707
+ # check's business.
708
+ STATIC_CLASS = re.compile(r'class="([^"$<>{}]*)"')
709
+
710
+
711
+ def check_style_defined(
712
+ styles: list[Path], sources: dict[Path, str], users: list[Path]
713
+ ) -> list[Problem]:
714
+ """Report classes written into markup that no stylesheet defines.
715
+
716
+ The mirror of :func:`check_style_use`, and the half that was missing:
717
+ a rule nothing uses is dead weight, but an element wearing a class
718
+ nothing defines is a *bug*, and an invisible one -- the browser applies
719
+ no styling and reports nothing, so the element simply renders as a
720
+ plain block wherever it happens to sit. That is what
721
+ ``class="modal-backdrop"`` did to the whitelist's "add a tag" dialog:
722
+ a modal that was not a modal, on a page where every other one was.
723
+
724
+ Only literal, fully-written class attributes are checked, for the same
725
+ reason the other direction is generous: a name assembled at run time is
726
+ not there to be read.
727
+ """
728
+ defined: set[str] = set()
729
+ for path in styles:
730
+ defined |= set(css_classes(sources[path]))
731
+ found: list[Problem] = []
732
+ for path in users:
733
+ text = sources[path]
734
+ for match in STATIC_CLASS.finditer(text):
735
+ for name in match.group(1).split():
736
+ if name in defined:
737
+ continue
738
+ found.append(
739
+ Problem(
740
+ path,
741
+ _line_of(text, match.start()),
742
+ "C003",
743
+ f".{name} is worn by an element but no stylesheet defines it",
744
+ )
745
+ )
746
+ return found
747
+
748
+
749
+ # `what="Reading the event log"` or `what=${['A', 'B']}` on a Progress.
750
+ # Only the literal forms: a name assembled at run time is not there to be
751
+ # read, and is not this check's business.
752
+ PROGRESS_WHAT = re.compile(
753
+ r"\$\{Progress\}[^>]*?\bwhat=(\$\{\[[^\]]*\]\}|\"[^\"]*\")", re.S
754
+ )
755
+ # `ctx.worker.run("Reading the whitelist", ...)` and its f-string cousins.
756
+ # An f-string's literal head is what a prefix match can be held to.
757
+ WORKER_RUN = re.compile(r"worker\.run\(\s*f?\"([^\"{]*)")
758
+ # The other shape: a handler that picks its wording first and passes the
759
+ # variable -- `name = "Reading all properties"`, `label = f"Writing {x}"`.
760
+ WORKER_RUN_VAR = re.compile(r"worker\.run\(\s*([a-z_][a-z_0-9]*)\s*[,)]")
761
+ ASSIGNED = r"^\s*{name}\s*=[^\n]*"
762
+ # A whole double-quoted literal, f-string or not, scanned left to right so
763
+ # that what sits *between* two of them is never mistaken for a third.
764
+ PY_STRING = re.compile(r"f?\"((?:[^\"\\\n]|\\.)*)\"")
765
+
766
+
767
+ def operation_names(api_source: str) -> set[str]:
768
+ """Return the operation names ``web/api.py`` publishes to the link.
769
+
770
+ Most are written at the call. The handlers that choose their wording
771
+ first -- one name for a category and another for the whole charger --
772
+ pass a local instead, so the literals assigned to any local that
773
+ reaches ``worker.run`` count too.
774
+ """
775
+ names = {name for name in WORKER_RUN.findall(api_source) if name}
776
+ for local in set(WORKER_RUN_VAR.findall(api_source)):
777
+ for line in re.findall(
778
+ ASSIGNED.format(name=re.escape(local)), api_source, re.M
779
+ ):
780
+ names.update(
781
+ head
782
+ for literal in PY_STRING.findall(line)
783
+ if (head := literal.split("{")[0])
784
+ )
785
+ return names
786
+
787
+
788
+ def check_operation_names(
789
+ scripts: list[Path], sources: dict[Path, str], api: Path
790
+ ) -> list[Problem]:
791
+ """Report progress bars waiting on an operation nobody publishes.
792
+
793
+ ``Progress`` matches the running operation by prefix, so a panel's
794
+ ``what`` is half of a pair whose other half is a string literal in
795
+ ``web/api.py``. Neither language's tooling can see the pair: Biome
796
+ does not read Python, pytest does not read the templates, and the
797
+ failure is a bar that never draws -- which is exactly how the
798
+ transactions tab shipped with "Reading *the* charging sessions"
799
+ against a worker saying "Reading charging sessions".
800
+
801
+ Both directions of the match are wrong in their own way and both are
802
+ reported. A ``what`` no operation starts with waits forever; a
803
+ ``what`` shorter than the operation it means matches every other
804
+ read that shares its opening words, which is how the properties
805
+ panel came to draw a bar for the event log.
806
+
807
+ One operation may publish under several names -- an f-string's head
808
+ and the fuller wording it grows into, "Reading the event log" and
809
+ "Reading the event log since 7d" -- so what is counted is families,
810
+ not literals: names that all begin with the shortest of them are one
811
+ read under different words, and naming that read is correct.
812
+ """
813
+ published = operation_names(api.read_text(encoding="utf-8"))
814
+ found: list[Problem] = []
815
+ for path in scripts:
816
+ text = sources[path]
817
+ for match in PROGRESS_WHAT.finditer(text):
818
+ line = _line_of(text, match.start())
819
+ for name in re.findall(r"['\"]([^'\"]*)['\"]", match.group(1)):
820
+ matched = sorted(op for op in published if op.startswith(name))
821
+ if not matched:
822
+ found.append(
823
+ Problem(
824
+ path,
825
+ line,
826
+ "C004",
827
+ f"no operation in web/api.py begins {name!r}, "
828
+ "so this progress bar never draws",
829
+ )
830
+ )
831
+ elif not all(op.startswith(matched[0]) for op in matched):
832
+ found.append(
833
+ Problem(
834
+ path,
835
+ line,
836
+ "C004",
837
+ f"{name!r} is a prefix of several unrelated operations "
838
+ f"({', '.join(matched[:3])}...), so this bar draws for "
839
+ "other panels' reads",
840
+ )
841
+ )
842
+ return found
843
+
844
+
845
+ # `width="full"` on a Card, and the three things that earn it: a table, the
846
+ # log, and a plot -- a chart is drawn to the width it is given, and one bar
847
+ # per day over a year of charging is a row's worth of content by any reading
848
+ # of the word.
849
+ CARD_FULL = re.compile(r"\bwidth=\"full\"")
850
+ ROW_WIDE = re.compile(r"<table\b|class=\"logs\b|<svg\b")
851
+ # Components are top-level functions here, so the one a match sits in runs
852
+ # from the `function` line above it to the next one at column zero.
853
+ TOP_LEVEL_FUNCTION = re.compile(r"^(?:export\s+)?function\s+\w+", re.M)
854
+
855
+
856
+ def enclosing_function(text: str, index: int) -> str:
857
+ """Return the top-level function body an offset falls inside."""
858
+ starts = [m.start() for m in TOP_LEVEL_FUNCTION.finditer(text)]
859
+ before = [start for start in starts if start <= index]
860
+ if not before:
861
+ return text
862
+ after = [start for start in starts if start > index]
863
+ return text[before[-1] : after[0] if after else len(text)]
864
+
865
+
866
+ def check_card_widths(scripts: list[Path], sources: dict[Path, str]) -> list[Problem]:
867
+ """Report cards claiming a whole row without a row's worth of content.
868
+
869
+ ``.card.full`` spans the grid from edge to edge, which also *ends* the
870
+ row -- every card after it starts a new one. The stylesheet has said
871
+ since the grid was built that this is for content genuinely a row wide
872
+ -- a table, the log, a plot -- and nine cards had it anyway: short
873
+ panels spanning 1600px with the rest of the tab stacked underneath
874
+ them.
875
+
876
+ A comment cannot be held to, so this is the same sentence as a check.
877
+ Only the literal ``width="full"`` is read: a card whose width follows
878
+ what it is holding writes ``width=${open ? 'full' : undefined}``, and
879
+ that one is answering the question already.
880
+ """
881
+ found: list[Problem] = []
882
+ for path in scripts:
883
+ text = sources[path]
884
+ for match in CARD_FULL.finditer(text):
885
+ if ROW_WIDE.search(enclosing_function(text, match.start())):
886
+ continue
887
+ found.append(
888
+ Problem(
889
+ path,
890
+ _line_of(text, match.start()),
891
+ "C005",
892
+ 'width="full" ends the row for every card after it, and '
893
+ 'this one holds no table, log or plot -- use "wide" or '
894
+ "leave it a column",
895
+ )
896
+ )
897
+ return found
898
+
899
+
900
+ USAGE = (
901
+ "usage: python -m devicectl.devtools.frontlint <static-root> "
902
+ "[--core <shared-static-root>] [--library]"
903
+ )
904
+
905
+
906
+ def main(argv: list[str] | None = None) -> int:
907
+ """Check the static tree named on the command line."""
908
+ args = list(sys.argv[1:] if argv is None else argv)
909
+ library = "--library" in args
910
+ args = [arg for arg in args if arg != "--library"]
911
+ core: Path | None = None
912
+ if "--core" in args:
913
+ at = args.index("--core")
914
+ if at + 1 >= len(args):
915
+ print(USAGE)
916
+ return 2
917
+ core = Path(args[at + 1])
918
+ del args[at : at + 2]
919
+ if not args:
920
+ print(USAGE)
921
+ return 2
922
+ root = Path(args[0])
923
+ problems = check_tree(root, core=core, library=library)
924
+ for problem in problems:
925
+ print(problem.render(root.resolve().parent))
926
+ print(
927
+ f"frontlint: {len(problems)} problem(s) in {root}"
928
+ if problems
929
+ else f"frontlint: clean ({root})"
930
+ )
931
+ return 1 if problems else 0
932
+
933
+
934
+ if __name__ == "__main__":
935
+ raise SystemExit(main())