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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- 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())
|