4"""Lint and format-check the devcontainer: the Dockerfile and the zshrc.
6WHY THESE TWO FILES, TOGETHER
7=============================
8`.devcontainer/Dockerfile` pins every tool version CI resolves -- clang-format
922, the ARM toolchain by sha256, ruff, shellcheck, shfmt, cmake-format,
10yamllint, actionlint. A defect in it changes what every other gate in this
11repository runs. It was, until #371, linted and formatted by nothing at all.
12`.devcontainer/zshrc` is copied to ~/.zshrc at image build time and is the
13other half of the same artefact. They are checked as one unit because they are
14one deliverable: the container.
16THE TOOLS, AND WHY EACH ONE
17---------------------------
18Dockerfile -> hadolint (pinned 2.14.0). The obvious and only serious candidate:
19 a single static binary, the same provisioning shape actionlint and shfmt
20 already use. It found a real defect on its first run -- DL4006, eight
21 `curl -fsSL <url> | tar -x` pipelines running under `/bin/sh -c`, where the
22 pipeline's status is that of `tar` alone. A failed download produced an empty
23 tarball, tar succeeded on it, and the image was built with the pinned tool
24 silently absent. That is precisely the class check_errexit_masking.py exists
25 to catch in shell, and it was live in the file that provisions CI. Fixed by
26 the `SHELL ["/bin/bash", "-o", "pipefail", "-c"]` directive. Rule
27 configuration and the one deliberate ignore live in .hadolint.yaml.
29zshrc -> `zsh -n` (zsh 5.9, already in the image; no new provisioning).
30 There is no zsh linter and no zsh formatter, and this was checked rather than
33 * ShellCheck refuses zsh outright ("ShellCheck only supports sh/bash/dash/
34 ksh"). Running it as `--shell=bash` is worse than not running it: this
35 very file uses `${(%):-%n}` and `plugins=(...)`, zsh parameter-expansion
36 flags and array syntax that bash cannot parse, so bash-mode ShellCheck
37 reports errors that are not errors. A linter that is wrong about the
38 file is not partial coverage, it is noise that trains people to ignore
39 the gate. That is why lint_coverage_rules.py classifies `.zsh` as its
40 own class rather than folding it into `shell`.
41 * shfmt likewise handles sh/bash/mksh only.
42 * `zsh -n` parses the file with the real zsh grammar and reports syntax
43 errors without executing anything. It is a genuine check, it is the only
44 one that exists, and it is what runs here.
46 Being honest about the limit: `zsh -n` catches syntax, not semantics. It
47 would not notice a misspelled plugin name. That is the ceiling of what is
48 available for the language, and it is still strictly more than the nothing
53Neither tool rewrites its input, so the formatter half is a canonical-form
54checker -- the same arrangement lint_coverage_rules.py already documents for
55justfiles and linker scripts, where nothing in the ecosystem rewrites the file
56either. DC001 rejects the layout deviations that a formatter would otherwise
57fix: non-ASCII bytes, CRLF, a missing final newline, tab indentation and
60Run with --selftest to prove both linters fire on deliberately broken input and
61stay quiet on the real files.
64from __future__
import annotations
72from pathlib
import Path
74sys.path.insert(0, str(Path(__file__).resolve().parent))
76from lint_coverage_rules
import EXT_CLASS, NAME_CLASS
77from selftest_assert
import expect, report
80THIRD_PARTY_PREFIXES = (
"libs/third_party/",
"apps/shared_libs/third_party/")
84 [
"git",
"rev-parse",
"--show-toplevel"],
95OWNED_CLASSES = (
"dockerfile",
"zsh")
97HADOLINT_VERSION =
"2.14.0"
105 """One rule violation, reported as path:line: [CODE] message."""
107 def __init__(self, rel: str, line: int, code: str, msg: str) ->
None:
108 """Record one finding; all four fields are required and none is derived."""
109 self.rel, self.line, self.code, self.msg = rel, line, code, msg
111 def __str__(self) -> str:
112 """Render as ``rel:line: [CODE] message`` -- editor-jumpable."""
113 return f
"{self.rel}:{self.line}: [{self.code}] {self.msg}"
116def classify(rel: str) -> str |
None:
117 """The shared class of `rel`, by exact name then extension."""
118 name = rel.rsplit(
"/", 1)[-1]
119 if name
in NAME_CLASS:
120 return NAME_CLASS[name]
122 suffix =
"." + name.rsplit(
".", 1)[-1]
123 return EXT_CLASS.get(suffix.lower())
127def targets() -> list[str]:
128 """Every first-party Dockerfile and zsh file, repo-relative and sorted."""
129 proc = subprocess.run(
130 [
"git",
"ls-files",
"-z"],
140 for rel
in proc.stdout.split(
"\0")
142 and not rel.startswith(THIRD_PARTY_PREFIXES)
143 and rel !=
".devcontainer/p10k.zsh"
144 and classify(rel)
in OWNED_CLASSES
148def check_format(rel: str, raw: bytes) -> list[Finding]:
149 """DC001 -- the canonical-form rules, on raw bytes."""
150 findings: list[Finding] = []
152 def add(line: int, msg: str) ->
None:
153 """Append one DC001 finding, closing over this file's relative path."""
154 findings.append(Finding(rel, line,
"DC001", msg))
157 text = raw.decode(
"ascii")
158 except UnicodeDecodeError
as exc:
159 add(raw[: exc.start].count(b
"\n") + 1, f
"non-ASCII byte 0x{raw[exc.start]:02x}")
160 text = raw.decode(
"ascii", errors=
"replace")
162 add(raw.split(b
"\r\n")[0].count(b
"\n") + 1,
"CRLF line ending")
163 if raw
and not raw.endswith(b
"\n"):
164 add(text.count(
"\n") + 1,
"no final newline")
165 for num, line
in enumerate(text.splitlines(), start=1):
166 if line.startswith(
"\t"):
167 add(num,
"tab indentation (use spaces)")
168 if line != line.rstrip():
169 add(num,
"trailing whitespace")
173def check_uv_execution(rel: str, text: str) -> list[Finding]:
174 """DC004 -- execute uv only through the authenticated bootstrap runner."""
175 boundary =
"WORKDIR /opt/ra8-python-project\n"
176 if boundary
not in text:
178 start = text.find(boundary) + len(boundary)
179 environment =
'ENV PYTHONDONTWRITEBYTECODE="1"'
180 end = text.find(f
"\n{environment}", start)
181 if text.count(boundary) != 1
or text.count(environment) != 1
or end < 0:
182 return [Finding(rel, 1,
"DC004",
"uv provisioning block is not structurally bounded")]
183 block = text[start:end]
184 normalized =
" ".join(block.replace(
"\\\n",
" ").split())
185 expected =
" ".join(EXPECTED_UV_RUN_BLOCK.replace(
"\\\n",
" ").split())
186 raw_uv = re.compile(
r"(?:^|[;&|]\s*)(?:[^\s;]+/)?uv(?:\s|$)")
188 raw_uv.search(
" ".join(instruction.replace(
"\\\n",
" ").split()))
189 for instruction
in re.findall(
r"(?ms)^RUN .*?(?=\n(?:RUN|ENV|ARG|COPY|WORKDIR|#)|\Z)", text)
190 if instruction != block
192 unsafe = normalized != expected
or other_raw
195 line = text[:start].count(
"\n") + 1
196 return [Finding(rel, line,
"DC004",
"uv execution bypasses authenticated bytes")]
199def run_hadolint(rel: str, path: Path) -> list[Finding]:
200 """DC002 -- hadolint over one Dockerfile. Config comes from .hadolint.yaml.
202 hadolint prints ``<file>:<line> <CODE> <severity>: <message>``. That leading
203 ``<file>:<line>`` is re-parsed rather than passed through, so a finding reads
204 like every other checker here instead of carrying a second, absolute copy of
205 the path inside the message.
207 proc = subprocess.run(
208 [
"hadolint",
"--no-color", str(path)],
214 findings: list[Finding] = []
216 for raw
in (proc.stdout + proc.stderr).splitlines():
221 if line.startswith(prefix):
222 rest = line[len(prefix) :]
223 head, _, tail = rest.partition(
" ")
225 num, line = int(head), tail
226 findings.append(Finding(rel, num,
"DC002", line))
230def run_zsh_syntax(rel: str, path: Path) -> list[Finding]:
231 """DC003 -- `zsh -n` over one zsh file. Parses only; never executes."""
232 proc = subprocess.run(
233 [
"zsh",
"-n", str(path)],
239 if proc.returncode == 0:
241 detail = (proc.stderr + proc.stdout).strip()
or f
"zsh -n exited {proc.returncode}"
246 if detail.startswith(prefix):
247 rest = detail[len(prefix) :]
248 head, _, tail = rest.partition(
":")
250 num, detail = int(head), tail.strip()
251 return [Finding(rel, num,
"DC003", f
"zsh syntax error: {detail}")]
254def check_one(rel: str, root: Path = REPO_ROOT) -> list[Finding]:
255 """Every finding for one devcontainer file."""
257 findings = check_format(rel, path.read_bytes())
259 if cls ==
"dockerfile":
260 findings.extend(check_uv_execution(rel, path.read_text(encoding=
"ascii")))
261 findings.extend(run_hadolint(rel, path))
263 findings.extend(run_zsh_syntax(rel, path))
267def require_tools() -> int:
268 """Fail loudly when a linter is absent. A gate must never degrade to a no-op."""
270 if shutil.which(
"hadolint")
is None:
272 f
"hadolint (pinned {HADOLINT_VERSION}) -- https://github.com/hadolint/hadolint/releases"
274 if shutil.which(
"zsh")
is None:
275 missing.append(
"zsh -- apt-get install zsh (already in the devcontainer image)")
277 print(
"check_devcontainer.py: FATAL -- required tool(s) absent:", file=sys.stderr)
279 print(f
" {item}", file=sys.stderr)
281 " This gate FAILS rather than skipping: a checker whose tool is\n"
282 " missing reports nothing, and nothing is indistinguishable from clean.",
289GOOD_DOCKERFILE = b
"""FROM ubuntu:24.04
290SHELL ["/bin/bash", "-o", "pipefail", "-c"]
296BAD_DOCKERFILE = b
"""FROM ubuntu:24.04
298RUN curl -fsSL http://example.com/a.tgz | tar -xz
301EXPECTED_UV_RUN_BLOCK = (
303 'UV_PROJECT_ENVIRONMENT="${PYTHON_TOOL_VENV}" UV_PYTHON_DOWNLOADS=never '
304 "/usr/bin/python3 -I -S /opt/ra8-uv-bootstrap/bootstrap_uv.py "
305 "--cache-root /opt/ra8-uv-cache --ensure-and-run --no-config sync "
306 "--locked --only-group ci --no-install-project --python /usr/bin/python3; "
307 "UV_PYTHON_DOWNLOADS=never "
308 "/usr/bin/python3 -I -S /opt/ra8-uv-bootstrap/bootstrap_uv.py "
309 "--cache-root /opt/ra8-uv-cache --run --no-config lock --check; "
310 "UV_PYTHON_DOWNLOADS=never "
311 "/usr/bin/python3 -I -S /opt/ra8-uv-bootstrap/bootstrap_uv.py "
312 "--cache-root /opt/ra8-uv-cache --run --no-config pip check "
313 '--python "${PYTHON_TOOL_VENV}/bin/python3"; '
314 '"${PYTHON_TOOL_VENV}/bin/python3" -c '
315 '"import PIL, clang.cindex, dotenv, kasa, serial, usb.core, yaml"; '
316 '"${PYTHON_TOOL_VENV}/bin/ruff" --version; '
317 '"${PYTHON_TOOL_VENV}/bin/cmake-format" --version; '
318 '"${PYTHON_TOOL_VENV}/bin/yamllint" --version; '
319 '"${PYTHON_TOOL_VENV}/bin/gcovr" --version; '
320 'rm -f -- "${PYTHON_TOOL_VENV}/.lock"'
324 "WORKDIR /opt/ra8-python-project\n"
325 f
"{EXPECTED_UV_RUN_BLOCK}\n"
326 'ENV PYTHONDONTWRITEBYTECODE="1" \\\n'
327 ' PYTHONNOUSERSITE="1" \\\n'
328 ' RA8_TOOL_VENV="${PYTHON_TOOL_VENV}"\n'
331GOOD_ZSHRC = b
"""export PATH="$HOME/.local/bin:$PATH"
332if [[ -r ~/.p10k.zsh ]]; then
337BAD_ZSHRC = b
"if [[ -r ~/.p10k.zsh ]; then\n source ~/.p10k.zsh\n"
340def _assert_dockerfile(root: Path, failures: list[str]) ->
None:
341 """Assert hadolint fires on a bad Dockerfile and is silent on a clean one."""
342 (root /
".devcontainer/Dockerfile").write_bytes(GOOD_DOCKERFILE)
343 got = check_one(
".devcontainer/Dockerfile", root)
344 expect(
not got, f
"a clean Dockerfile yields no findings (got {got})", failures)
346 (root /
".devcontainer/Dockerfile").write_bytes(BAD_DOCKERFILE)
347 got = check_one(
".devcontainer/Dockerfile", root)
348 blob =
" ".join(f.msg
for f
in got)
349 expect(any(f.code ==
"DC002" for f
in got),
"hadolint fires on a bad Dockerfile", failures)
350 expect(
"DL3020" in blob,
" ... reporting DL3020 (ADD instead of COPY)", failures)
351 expect(
"DL4006" in blob,
" ... reporting DL4006 (pipe without pipefail)", failures)
354def _assert_uv_execution(failures: list[str]) ->
None:
355 """Assert authenticated uv execution passes and captured-path execution fails."""
356 got = check_uv_execution(
".devcontainer/Dockerfile", GOOD_UV_BLOCK)
357 expect(
not got, f
"authenticated uv Docker execution is clean (got {got})", failures)
359 (
"RUN set -eux;",
"RUN set -eux; /tmp/uv --version;",
"raw uv execution"),
360 (
"--run --no-config lock",
"--verify-cache --no-config lock",
"mode replacement"),
361 (
"--run --no-config pip",
"--no-config --run pip",
"argv reordering"),
362 (
"--no-config lock --check;",
"--no-config lock --check || true;",
"status masking"),
364 'ENV PYTHONDONTWRITEBYTECODE="1"',
365 'ENV PYTHONDONTWRITEBYTECODE="0"',
366 "bytecode protection removal",
369 for old, new, label
in mutations:
370 bad = GOOD_UV_BLOCK.replace(old, new, 1)
371 got = check_uv_execution(
".devcontainer/Dockerfile", bad)
372 expect(any(finding.code ==
"DC004" for finding
in got), f
"{label} fires DC004", failures)
375def _assert_zshrc(root: Path, failures: list[str]) ->
None:
376 """Assert zsh -n fires on a broken zshrc and accepts zsh-only syntax.
378 The negative case is the whole reason this file's class is ``zsh`` and not
379 ``shell``: bash cannot parse either of those lines, so checking them with
380 bash would report a syntax error in a correct file.
382 (root /
".devcontainer/zshrc").write_bytes(GOOD_ZSHRC)
383 got = check_one(
".devcontainer/zshrc", root)
384 expect(
not got, f
"a clean zshrc yields no findings (got {got})", failures)
386 (root /
".devcontainer/zshrc").write_bytes(BAD_ZSHRC)
387 got = check_one(
".devcontainer/zshrc", root)
389 any(f.code ==
"DC003" for f
in got),
390 "zsh -n fires on a zshrc with a syntax error",
394 (root /
".devcontainer/zshrc").write_bytes(
395 b
'H=${(%):-%n}\nplugins=(git gh docker)\nprint -r -- "$H ${#plugins}"\n'
397 got = check_one(
".devcontainer/zshrc", root)
400 f
"zsh-specific syntax is accepted, not flagged (got {got})",
405def _assert_format_rules(failures: list[str]) ->
None:
406 """Assert each DC001 byte-level formatting rule fires on its own payload."""
407 for payload, want, label
in (
408 (b
"FROM x\n\tRUN y\n",
"tab indentation",
"tab indentation fires DC001"),
409 (b
"FROM x \n",
"trailing whitespace",
"trailing whitespace fires DC001"),
410 (b
"FROM x",
"no final newline",
"a missing final newline fires DC001"),
411 (b
"FROM x\r\ny\n",
"CRLF",
"a CRLF ending fires DC001"),
412 (b
"FROM \xc2\xa9\n",
"non-ASCII",
"a non-ASCII byte fires DC001"),
414 got = check_format(
"f", payload)
415 expect(any(want
in f.msg
for f
in got), label, failures)
418def _assert_scope(failures: list[str]) ->
None:
419 """Assert the real scope resolves both files and excludes the vendored theme."""
422 len(found) >= FILE_FLOOR,
423 f
"the real scope resolves both files (got {found})",
427 ".devcontainer/p10k.zsh" not in found,
428 "the vendored p10k theme config stays out of scope",
433def selftest() -> int:
434 """Assert both linters fire on broken input and stay quiet on good input."""
435 print(
"check_devcontainer.py --selftest")
436 failures: list[str] = []
438 if require_tools() != 0:
441 with tempfile.TemporaryDirectory()
as tmp:
443 (root /
".devcontainer").mkdir()
444 _assert_dockerfile(root, failures)
445 _assert_uv_execution(failures)
446 _assert_zshrc(root, failures)
447 _assert_format_rules(failures)
449 _assert_scope(failures)
450 return report(failures)
453def main(argv: list[str]) -> int:
454 """Lint and format-check the devcontainer definition.
456 The devcontainer is what gives the Mac an Ubuntu userland to run the CI
457 suite in, so a broken definition does not fail loudly -- it makes `just
458 ci` unable to run at all, on the one platform that cannot verify natively.
460 Returns 0 when clean, 1 on any finding or a failing selftest.
462 ap = argparse.ArgumentParser(description=
"Lint and format-check the devcontainer files")
463 ap.add_argument(
"--selftest", action=
"store_true", help=
"assert both directions")
464 ap.add_argument(
"--list-files", action=
"store_true", help=
"print the scanned file list")
465 args = ap.parse_args(argv[1:])
472 print(
"\n".join(files))
475 if require_tools() != 0:
477 if len(files) < FILE_FLOOR:
479 f
"check_devcontainer.py: FATAL -- {len(files)} file(s) found, floor is "
480 f
"{FILE_FLOOR}. An empty scan reports success because it saw nothing.",
485 findings: list[Finding] = []
487 findings.extend(check_one(rel))
490 f
"\n{len(findings)} finding(s) in {len(files)} devcontainer file(s):\n", file=sys.stderr
492 for finding
in findings:
493 print(f
" {finding}", file=sys.stderr)
495 print(f
"check_devcontainer.py: {len(files)} file(s), no findings.")
499if __name__ ==
"__main__":
500 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.