mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-18 18:41:41 -07:00
- grblhal-glowforge badc832 -> e159720 (0.1.21 -> 0.1.22): a soft reset
during motion gets the kernel's ramp and the position is read back
from the kernel's step counters, a reset that lands after the core's
own idle still gets the stop, the owed step is taken only between
callbacks, a position that stays lost pins the lens and tells the
sender, the homing cycle masks are pinned so a write cannot drop the
lens hold, the test hooks (GF_SWITCH_DEV included) are honored only in
a null-sink run through one gate, the underrun fail-safe log line
names its entry, and the latch_test and serial_test cases that hold
each.
- forgeext bb75b23 -> ebd9592 (0.1.4 -> 0.1.5): the storage quota counts
entries (one per 4 KiB) and the unlinked files a service holds open or
mapped, bounded, every 10 s; a quarantine the host could not write is
kept under the run directory, taken up at the next start or the first
turn the state reads, and dropped by the write or the lift; the root's
prepare turns current back to the state's version, drops only a
versioned tree, and leaves a package it cannot turn refused while the
host runs the others; a manifest that cannot be read holds its package
out by name; the required-holds marker directory is one literal.
- forgectrl 6c2bb38 -> b235be3 (0.1.33 -> 0.1.34): a diagnostic start
the cooling engine refuses goes once more a second later, canceled by
a later press or the Abort, which clears its text; the sysfs root, the
switch device and the run directory are honored only in a host-test
run through one gate (src/testhook.h, with its HOST_TESTED row here);
test_routes holds the panel's retry text to the daemon's and the
environment scan reads secure_getenv; the operator's texts say Grbl
sender; holds_wire_test holds the required-holds directory to
forgeext's and a marker alone holds.
- forgefirm-app 0f59c54 -> 6fb146a (0.1.32 -> 0.1.33): the shared
AGENTS.md block names a Grbl sender; nothing it installs changes.
- kas lock: meta-openglow b7626be -> c542ce6, the head that pins the
kernel module dab378d, python3-gfhardware 6fb146a and gfutilities
b9e74b8.
Proof: the four repositories' CI runs on the pushed heads are green
(grblHAL-glowforge 39170662884, forgeext 39170677776, forgectrl
39168946865 on its second attempt, python3-gfhardware 39168949407), and forgefirm's own
forgetest-ci on 0de9051 (39168937632). bitbake -c fetch of each pinned
recipe from these pins succeeds. The tree manifest from the pins holds
forgectrl at b235be3 (232 files), forgeext at ebd9592 (90), grblhal at
e159720 (178) and the app at 6fb146a (46); the coverage lint over it
finds no uncovered path and no row problem (43 tests), with the row for
forgectrl src/testhook.h selecting host_start.sh in build.yml;
check-ui-vendor finds the shared UI files identical; test_tree_manifest
passes (7); the gate self-check refuses the empty artifact (rc 1).
Acceptance: the pins move the fingerprints of every test whose covers
name the four components, and the whole catalog runs on the next image.
1253 lines
57 KiB
Python
1253 lines
57 KiB
Python
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""The image manifest and the domain fingerprint.
|
|
|
|
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
|
identifies the build's inputs: for every component the pinned revision and
|
|
one [path, blob-id] pair per source file, plus the platform identity
|
|
(machine, kernel modules directory, device tree hashes, layer content
|
|
hashes). This module loads it and computes a test's *domain fingerprint*:
|
|
the hash of the source files its coverage globs select, plus the platform,
|
|
plus the test's own implementation. A recorded PASS applies to a build
|
|
exactly when the fingerprint recomputed from that build's manifest is the
|
|
same - the same code runs on the board and in the release gate.
|
|
"""
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import posixpath
|
|
import re
|
|
|
|
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
|
|
|
|
# Components that ship only on the dev image. They can never be part of a
|
|
# fingerprint (the release manifest lacks them, so the gate could not
|
|
# recompute it); the test implementation is folded in separately.
|
|
DEV_ONLY_COMPONENTS = ("forgetest",)
|
|
|
|
|
|
def canonical(obj):
|
|
"""Canonical JSON: sorted keys, no whitespace - the hashing form."""
|
|
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
|
|
|
|
def sha256_text(text):
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
@functools.lru_cache(maxsize=512)
|
|
def glob_to_regex(pattern):
|
|
"""Coverage glob -> anchored regex. '**' spans directories, '*' and '?'
|
|
stay inside one path segment. Paths use '/' (git paths). Cached: the
|
|
catalog matches the same few hundred globs over and over."""
|
|
out = []
|
|
i, n = 0, len(pattern)
|
|
while i < n:
|
|
c = pattern[i]
|
|
if c == "*":
|
|
if pattern[i:i + 2] == "**":
|
|
# '**/' also matches zero directories
|
|
if pattern[i:i + 3] == "**/":
|
|
out.append("(?:.*/)?")
|
|
i += 3
|
|
continue
|
|
out.append(".*")
|
|
i += 2
|
|
continue
|
|
out.append("[^/]*")
|
|
elif c == "?":
|
|
out.append("[^/]")
|
|
else:
|
|
out.append(re.escape(c))
|
|
i += 1
|
|
return re.compile("^" + "".join(out) + "$")
|
|
|
|
|
|
def match_files(files, pattern):
|
|
"""(path, blob) pairs from a component's file list that the glob selects."""
|
|
rx = glob_to_regex(pattern)
|
|
return [(p, b) for p, b in files if rx.match(p)]
|
|
|
|
|
|
# Paths that carry no target behavior: docs, CI, the components' own unit
|
|
# tests, licenses, editor setup. Outside every fingerprint (a README edit
|
|
# re-requires nothing) and outside the coverage lint (no test has to name
|
|
# them). Reviewed with the catalog: widening this list is a change like
|
|
# any other. "*" applies to every component.
|
|
NON_BEHAVIORAL = [
|
|
("*", ".github/**"),
|
|
("*", ".gitignore"),
|
|
("*", ".gitattributes"),
|
|
("*", ".gitmodules"),
|
|
("*", "**/*.md"),
|
|
("*", "LICENSE*"),
|
|
("*", "COPYING*"),
|
|
("*", "docs/**"),
|
|
("*", "tests/**"),
|
|
("*", "graphify-out/**"),
|
|
("*", "**/.gitkeep"),
|
|
("*", ".devcontainer/**"),
|
|
("*", ".vscode/**"),
|
|
("*", ".env.example"),
|
|
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
|
|
("forgectrl", "examples/**"), # clients of the remote API, run on another computer
|
|
# forgeext's recipe installs the binary and its init script, and nothing of these: the author's kit
|
|
# and the host tools run off the image (OpenGlow's own packages have repositories of their own).
|
|
("forgeext", "sdk/**"),
|
|
("forgeext", "template/**"),
|
|
("forgeext", "tools/**"),
|
|
]
|
|
|
|
|
|
# Paths the list above would take out that are behavior all the same: forgectrl embeds its advisory
|
|
# documents in the binary, serves them, and records the operator's consent to a document by its hash, so
|
|
# an edited advisory is a changed consent and must move every fingerprint that covers it.
|
|
BEHAVIORAL = [
|
|
("forgectrl", "docs/advisories/**"),
|
|
]
|
|
|
|
|
|
# Paths a host test in CI proves, which no catalog test covers. The catalog
|
|
# is for what needs the machine or an operator; software whose inputs can be
|
|
# emulated is proven on a host. A row is (component, glob, host test, CI
|
|
# workflow), the last two named as "<repository>:<path>": a component of
|
|
# the manifest (its tests/ and .github/ files are listed there), or
|
|
# "forgefirm" for this repository (the null-sink harnesses under
|
|
# scripts/bench/, which grblhal-glowforge's workflow runs). The workflow
|
|
# must run the host test: on a push or a pull_request with no branches,
|
|
# tags or paths filter under it, and no types under the pull_request
|
|
# (one of the two events is enough), in a step
|
|
# that runs on every run of the workflow and whose failure fails it (no
|
|
# `if:` other than `true` and no `continue-on-error:` other than `false`
|
|
# on the step or its job, every job the job's `needs:` names one that
|
|
# runs on every run, and no key with one stray quote where the lint
|
|
# reads the workflow's keys), under bash or sh, on a line of the step's
|
|
# run text whose failure ends the step (the command alone on its line,
|
|
# or in a pipeline under pipefail, with errexit set and no line before
|
|
# it that ends the step or opens a compound command: workflow_runs), and
|
|
# name it, or discover it (a glob, unittest discover, pytest on its
|
|
# directory). A host-tested path counts as covered in the coverage lint,
|
|
# and like every path no test covers it is in no fingerprint: a change
|
|
# there makes no acceptance run necessary. A path a catalog test
|
|
# exercises on the hardware belongs in that test's covers, not here.
|
|
HOST_TESTED = [
|
|
# An extension package's holds as the engine reads them (forgeext's
|
|
# holdkeep_test and run_test prove the writer's side).
|
|
("forgectrl", "src/holds.*", "forgectrl:tests/holds_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The M-code relay to the package that answers (the controller's side is
|
|
# the null-sink mcode_test harness, under grblhal-glowforge's src/**).
|
|
("forgectrl", "src/mcode.*", "forgectrl:tests/mcode_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The digest primitives (SHA-256, HMAC, the constant-time compare) that
|
|
# the advisories' hashes, the tokens and the camera key are made with.
|
|
("forgectrl", "src/sha256.*", "forgectrl:tests/sha256_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The built-in extensions' table: the cloud entry, its refusals while it
|
|
# is off, the sweep when it goes off, the list.
|
|
("forgectrl", "src/builtin.*", "forgectrl:tests/builtin_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The setup record as a printable page: no script, the sheet id, a
|
|
# heading for each completed step, everything escaped.
|
|
("forgectrl", "src/recordhtml.*", "forgectrl:tests/recordhtml_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# An extension package's claim on the Grbl sender as the machine keeps
|
|
# it: the claim and its refusals, its end by the package, the operator
|
|
# or a stale keeper (forgeext's run_test proves the writer's side).
|
|
("forgectrl", "src/senderout.*", "forgectrl:tests/senderout_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# A package's check on the Setup page: each step held to its form, the
|
|
# check run through the host, the result the package's own (the routes
|
|
# against a live host's stand-in are tests/host_ext.sh).
|
|
("forgectrl", "src/wizpkg.*", "forgectrl:tests/wizpkg_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The camera key: made, kept, checked and rotated (the reads it opens
|
|
# from the LAN with the reads closed are tests/host_lan.sh).
|
|
("forgectrl", "src/camkey.*", "forgectrl:tests/camkey_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The log export's sanitizer: every identifying class taken out, the
|
|
# ordinary log text left as it was (the export's routes and the
|
|
# installer's directory on a host are tests/host_guard.sh).
|
|
("forgectrl", "src/sanitize.*", "forgectrl:tests/sanitize_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The package routes' relay to the extension host: the argument vector,
|
|
# the closed list of actions, the host's words, the catalog's fetch (the
|
|
# routes against a live host's stand-in are tests/host_ext.sh).
|
|
("forgectrl", "src/extpkg.*", "forgectrl:tests/extpkg_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The event stream: a button edge, a lease changing hands, telemetry
|
|
# (the live order, the take before the free, is tests/host_lease.sh).
|
|
("forgectrl", "src/events.*", "forgectrl:tests/events_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The crumb tray's mode as the daemon keeps it: its offset and what it
|
|
# refuses (the route and the panel's switch are tests/host_routes.sh).
|
|
("forgectrl", "src/tray.*", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The product gate on the firmware paths: firmware only, an extension
|
|
# package refused in words that say what it is (the upload route over
|
|
# a stand-in fwup is tests/host_update.sh).
|
|
("forgectrl", "src/fwproduct.*", "forgectrl:tests/fwproduct_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The controller port's client and its two operation sets (the motion
|
|
# routes and a package's jog through the host's credential are
|
|
# tests/host_routes.sh).
|
|
("forgectrl", "src/grblport.*", "forgectrl:tests/grblport_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The panel page as the daemon serves it: bundled, the theme and the
|
|
# save bar in it, and its policy frame-src 'none'.
|
|
("forgectrl", "src/ui.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
|
# The data and run directories from the environment: the SSH flag in the
|
|
# run directory, written and removed by POST /system/ssh.
|
|
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
|
# The one gate the test hooks are read through (the sysfs root, the switch
|
|
# device and the run directory, honored only in a host-test run): the
|
|
# daemon started with the hooks set and no gate ignores and names them.
|
|
("forgectrl", "src/testhook.h", "forgectrl:tests/host_start.sh", "forgectrl:.github/workflows/build.yml"),
|
|
# The first run in the page's order: the four documents at their hashes,
|
|
# the press step breathing teal, the press taken as the acceptance, the
|
|
# form steps through main.c's hooks; and the on-demand Extensions
|
|
# advisory, accepted at its hash with the typed phrase.
|
|
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
|
("forgectrl", "docs/advisories/extensions.md", "forgectrl:tests/host_setup.sh",
|
|
"forgectrl:.github/workflows/build.yml"),
|
|
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
|
|
"forgectrl:.github/workflows/build.yml"),
|
|
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
|
("forgectrl", "docs/advisories/privacy.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
|
("forgectrl", "docs/advisories/cloud-service.md", "forgectrl:tests/host_setup.sh",
|
|
"forgectrl:.github/workflows/build.yml"),
|
|
("forgectrl", "src/hooks.h", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
|
# The setup sheet id from the fuse serial, against golden ids.
|
|
("forgectrl", "src/sheetid.*", "forgectrl:tests/sheetid_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The logging path: the image's rsyslog.conf and the rendered rules, each
|
|
# logger in its own file at its level, a stray tag in system/, the kern
|
|
# facility in kernel/.
|
|
("forgectrl", "src/fflog.*", "forgectrl:tests/fflog_e2e.sh", "forgectrl:.github/workflows/build.yml"),
|
|
# POST /job's multipart reader: the form's parts reach the runner's
|
|
# start; a missing name, a $ line and a malformed boundary are refused
|
|
# with nothing started.
|
|
("forgectrl", "src/jobpost.*", "forgectrl:tests/jobpost_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The scoped tokens: the closed capability list, the judgment, the
|
|
# store that holds only hashes, and the extension host's credential
|
|
# (mode 0600, exactly the host's capabilities).
|
|
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The logging tree's paths, the level and target validators, the
|
|
# configured levels.
|
|
("forgectrl", "src/logs.*", "forgectrl:tests/logs_test.c", "forgectrl:.github/workflows/build.yml"),
|
|
# The extension host, each part to the test that drives it (the
|
|
# freeze, the supervisor, the machine client and the groups stay with
|
|
# exthost.armed-freeze, which covers them).
|
|
("forgeext", "src/api.*", "forgeext:tests/api_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/caps.*", "forgeext:tests/caps_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/evfeed.*", "forgeext:tests/evfeed_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/holdkeep.*", "forgeext:tests/holdkeep_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/httpreq.*", "forgeext:tests/httpreq_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/manifest.*", "forgeext:tests/manifest_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/quota.*", "forgeext:tests/quota_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/sandbox.*", "forgeext:tests/sandbox_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/settings.*", "forgeext:tests/settings_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/state.*", "forgeext:tests/state_test.c", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/netrules.*", "forgeext:tests/netrules_test.py", "forgeext:.github/workflows/build.yml"),
|
|
# A package's page asking its own service (forgeext call).
|
|
("forgeext", "src/call.*", "forgeext:tests/sdk_test.py", "forgeext:.github/workflows/build.yml"),
|
|
# Archives in and refused, and the signed index.
|
|
("forgeext", "src/install.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/pkg.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/index.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
|
# The daemon as built, end to end: its command line, its log, the
|
|
# killed host under the init script's wrapper.
|
|
("forgeext", "src/main.c", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "src/fflog.*", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
|
("forgeext", "CMakeLists.txt", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
|
]
|
|
|
|
# This repository's root, when the package runs from a checkout of it; a
|
|
# "forgefirm:" file is looked for there. Installed on an image, the package
|
|
# is not in a checkout, and such a reference is not checked.
|
|
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
REPO_ROOT = _ROOT if os.path.isdir(os.path.join(_ROOT, "meta-forgefirm")) else None
|
|
# The pinned sources scripts/manifest-from-tree.py fetches (one bare repo
|
|
# per source URL); a component's workflow is read from there. Without them
|
|
# (on an image) whether a workflow runs a host test is not checked.
|
|
SOURCES = os.path.join(REPO_ROOT, ".manifest-cache") if REPO_ROOT else None
|
|
|
|
|
|
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
|
|
for c, pat in BEHAVIORAL:
|
|
if c == comp and glob_to_regex(pat).match(path):
|
|
return False
|
|
for c, pat in allow:
|
|
if c in ("*", comp) and glob_to_regex(pat).match(path):
|
|
return True
|
|
return False
|
|
|
|
|
|
class Manifest:
|
|
def __init__(self, data):
|
|
self.data = data
|
|
self.components = data.get("components", {}) or {}
|
|
self.platform = data.get("platform", {}) or {}
|
|
self.image = data.get("image", {}) or {}
|
|
self.content_sha = data.get("content_sha256")
|
|
self._files = {}
|
|
|
|
@classmethod
|
|
def load(cls, path=None):
|
|
path = path or os.environ.get("FORGETEST_MANIFEST") or DEFAULT_PATH
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return cls(json.load(f))
|
|
|
|
@classmethod
|
|
def from_json(cls, text):
|
|
return cls(json.loads(text))
|
|
|
|
@property
|
|
def version(self):
|
|
return self.image.get("version") or "unknown"
|
|
|
|
@property
|
|
def image_name(self):
|
|
return self.image.get("name") or "unknown"
|
|
|
|
def files(self, component):
|
|
"""The (path, blob) pairs of a component, or None if the component
|
|
is not in this manifest. Built once per component: the manifest is
|
|
immutable and every coverage glob asks for the same lists."""
|
|
if component in self._files:
|
|
return self._files[component]
|
|
c = self.components.get(component)
|
|
out = None if c is None else [tuple(x) for x in c.get("files", [])]
|
|
self._files[component] = out
|
|
return out
|
|
|
|
def component_names(self):
|
|
return sorted(self.components)
|
|
|
|
def identity_sha(self):
|
|
"""sha256 of the acceptance-relevant identity: every component
|
|
except the dev-only ones, plus the platform. Informational (the
|
|
gate decides per test, by fingerprint)."""
|
|
comps = {k: v for k, v in self.components.items() if k not in DEV_ONLY_COMPONENTS}
|
|
return sha256_text(canonical({"components": comps, "platform": self.platform}))
|
|
|
|
|
|
def fingerprint(manifest, covers, extra=()):
|
|
"""The domain fingerprint of a coverage map on a manifest.
|
|
|
|
covers: iterable of (component, glob). extra: strings folded in after
|
|
the files (the test's own implementation hash). A component the
|
|
manifest lacks contributes a marker so the fingerprint is still
|
|
defined and distinct.
|
|
"""
|
|
parts = set()
|
|
for comp, pat in covers:
|
|
if comp in DEV_ONLY_COMPONENTS:
|
|
raise ValueError("coverage may not name the dev-only component %r" % comp)
|
|
files = manifest.files(comp)
|
|
if files is None:
|
|
parts.add((comp, "@missing", ""))
|
|
continue
|
|
for p, b in match_files(files, pat):
|
|
if non_behavioral(comp, p):
|
|
continue
|
|
parts.add((comp, p, b))
|
|
h = hashlib.sha256()
|
|
h.update(canonical(sorted(parts)).encode("utf-8"))
|
|
h.update(b"\n")
|
|
h.update(canonical(manifest.platform).encode("utf-8"))
|
|
for e in extra:
|
|
h.update(b"\n")
|
|
h.update(str(e).encode("utf-8"))
|
|
return h.hexdigest()
|
|
|
|
|
|
def empty_covers(manifest, tests):
|
|
"""Coverage entries that select no file of their component: a glob
|
|
that never matched (paths anchor at the repository root, so a file
|
|
under a recipe's subdirectory needs that directory in the glob), or
|
|
a component not in the manifest. Such an entry covers nothing, and
|
|
the test's fingerprint would not move with the file it meant.
|
|
Returns [(test id, component, glob)]. A glob selecting only
|
|
non-behavioral paths is hollow too: nothing it names is fingerprinted."""
|
|
out = []
|
|
for t in tests:
|
|
for comp, pat in t.covers:
|
|
files = manifest.files(comp)
|
|
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
|
|
out.append((t.id, comp, pat))
|
|
return out
|
|
|
|
|
|
def _ref_problem(manifest, ref, what, root):
|
|
"""Why a "<repository>:<path>" reference names no file, or None. A
|
|
component's file must be in the manifest; a "forgefirm:" one must exist
|
|
under `root`, and is not checked without one."""
|
|
repo, sep, path = ref.partition(":")
|
|
if not sep or not path:
|
|
return "names no repository:path for its %s" % what
|
|
if repo == "forgefirm":
|
|
if root is not None and not os.path.isfile(os.path.join(root, path)):
|
|
return "no such %s" % what
|
|
return None
|
|
if repo in DEV_ONLY_COMPONENTS or manifest.files(repo) is None:
|
|
return "no repository %r for its %s" % (repo, what)
|
|
if path not in {p for p, _b in manifest.files(repo)}:
|
|
return "no such %s" % what
|
|
return None
|
|
|
|
|
|
def read_source(manifest, ref, root=REPO_ROOT, sources=SOURCES):
|
|
"""(text, why) for a "<repository>:<path>" file: a "forgefirm:" file
|
|
from `root`, a component's from its pinned blob in the bare repo
|
|
`sources` holds for its source URL. (None, None) when it cannot be read
|
|
here (no root, no sources); (None, why) when it should have been and
|
|
was not, which is a defect, never a pass."""
|
|
import subprocess
|
|
repo, _sep, path = ref.partition(":")
|
|
if repo == "forgefirm":
|
|
if root is None:
|
|
return None, None
|
|
try:
|
|
with open(os.path.join(root, path), encoding="utf-8") as f:
|
|
return f.read(), None
|
|
except (OSError, UnicodeDecodeError) as e:
|
|
return None, "cannot be read: %s" % e
|
|
if not sources:
|
|
return None, None
|
|
comp = manifest.components.get(repo) or {}
|
|
blob = dict(manifest.files(repo) or []).get(path)
|
|
url = comp.get("source")
|
|
if not (blob and url):
|
|
return None, "cannot be read: no blob or source URL in the manifest"
|
|
cache = os.path.join(sources, hashlib.sha1(url.encode()).hexdigest()[:16])
|
|
try:
|
|
return subprocess.run(["git", "cat-file", "-p", blob], cwd=cache, capture_output=True,
|
|
check=True).stdout.decode("utf-8", "replace"), None
|
|
except (OSError, subprocess.CalledProcessError) as e:
|
|
return None, "cannot be read from %s: %s" % (cache, e)
|
|
|
|
|
|
def repo_name(manifest, repo):
|
|
"""A repository's GitHub name: its source URL's last part, or this
|
|
repository's."""
|
|
if repo == "forgefirm":
|
|
return "forgefirm"
|
|
url = ((manifest.components.get(repo) or {}).get("source") or "").rstrip("/")
|
|
name = url.rsplit("/", 1)[-1]
|
|
return name[:-4] if name.endswith(".git") else name
|
|
|
|
|
|
_STEP_KEYS = ("name", "uses", "run", "id", "if", "with", "env", "working-directory", "shell",
|
|
"continue-on-error", "timeout-minutes")
|
|
_STEP_RX = re.compile(r"^(\s*)-\s+(%s)\s*:" % "|".join(re.escape(k) for k in _STEP_KEYS))
|
|
# A mapping key, plain or in a matched pair of quotes ("if": is the same
|
|
# key as if:).
|
|
_KEY_RX = re.compile(r"""^(\s*)(?=(?:"[\w-]+"|'[\w-]+'|[\w-]+)\s*:)["']?([\w-]+)["']?\s*:\s*(.*)$""")
|
|
# A key with one stray quote (if": or "if:), which _KEY_RX does not read:
|
|
# YAML reads a key with the quote in its name, which no workflow key and
|
|
# no event has, or refuses the file.
|
|
_STRAY_RX = re.compile(r"""^\s*["']?[\w-]+["']?\s*:""")
|
|
_ON_RX = re.compile(r"""^(?:on|"on"|'on')\s*:\s*(.*)$""")
|
|
# What a job or a step says about whether it runs on every run of the
|
|
# workflow, and whether its failure fails the run.
|
|
_CONDITION_KEYS = ("if", "continue-on-error")
|
|
|
|
|
|
def _indent(line):
|
|
return len(line) - len(line.lstrip(" "))
|
|
|
|
|
|
def _stray_key(line):
|
|
"""Whether a line is a mapping key with one stray quote."""
|
|
return _KEY_RX.match(line) is None and _STRAY_RX.match(line) is not None
|
|
|
|
|
|
def _lines(text):
|
|
"""A workflow's lines with the comments dropped: whole comment lines,
|
|
and from ' #' to the end of a line."""
|
|
out = []
|
|
for ln in text.splitlines():
|
|
if ln.lstrip().startswith("#"):
|
|
continue
|
|
out.append(re.sub(r"\s+#.*$", "", ln).rstrip())
|
|
return out
|
|
|
|
|
|
def _scalar(val):
|
|
"""A key's value, a quoted scalar without its quotes (a quote inside a
|
|
command stays)."""
|
|
val = val.strip()
|
|
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
|
|
return val[1:-1]
|
|
return val
|
|
|
|
|
|
def workflow_triggers(text):
|
|
"""The events a workflow runs on: the keys of its `on:` mapping, the
|
|
items of its list (`on: [push, pull_request]`, or one per line), or
|
|
its one scalar (`on: push`). What is under an event (its branches,
|
|
its paths, a dispatch's inputs) is not an event (workflow_filters
|
|
reads the filters). A flow mapping (`on: {push: ...}`) is not read,
|
|
and names no event. An event key with one stray quote (`push":`) is
|
|
no event, and the workflow then reads as one that runs on none."""
|
|
lines = _lines(text)
|
|
for i, ln in enumerate(lines):
|
|
m = _ON_RX.match(ln) if not _indent(ln) else None
|
|
if not m:
|
|
continue
|
|
val = m.group(1).strip()
|
|
if val.startswith("["):
|
|
return {_scalar(x) for x in val.strip("[]").split(",") if x.strip()}
|
|
if val:
|
|
return set() if val.startswith("{") else {_scalar(val)}
|
|
out, at = set(), None
|
|
for child in lines[i + 1:]:
|
|
if not child.strip():
|
|
continue
|
|
ind = _indent(child)
|
|
if ind == 0:
|
|
break
|
|
at = ind if at is None else at
|
|
if ind != at:
|
|
continue
|
|
body = child.strip()
|
|
if body.startswith("- "):
|
|
out.add(_scalar(body[2:]))
|
|
elif _stray_key(child):
|
|
return set()
|
|
else:
|
|
km = _KEY_RX.match(child)
|
|
if km:
|
|
out.add(km.group(2))
|
|
return out
|
|
return set()
|
|
|
|
|
|
# What under a push or a pull_request of `on:` keeps the workflow from a
|
|
# run: a ref or a path the event does not match, and under a pull_request
|
|
# alone, an activity type the pull request's event is not.
|
|
_FILTER_KEYS = ("branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore")
|
|
_PULL_REQUEST_FILTER_KEYS = _FILTER_KEYS + ("types",)
|
|
|
|
|
|
def workflow_filters(text):
|
|
"""The events of a workflow's `on:` block mapping that carry a filter,
|
|
{event: [what]}: each `branches:`, `branches-ignore:`, `tags:`,
|
|
`tags-ignore:`, `paths:` or `paths-ignore:` key under the event, and
|
|
under a pull_request a `types:` key too, by name; a value on the
|
|
event's own line (`push: {branches: [main]}`), which the lint does
|
|
not read, as "a value the lint does not read"; and a key with one
|
|
stray quote under the event (`paths":`), as "a key the lint does not
|
|
read". Every filter counts: a push with only a branches or a tags
|
|
filter runs on no push of the other kind of ref, and a branch it
|
|
names is one the lint does not know, as the manifest carries no
|
|
branch. A pull_request under `types:` runs on the activity types it
|
|
names and no other, so one under `types: [closed]` runs on no change
|
|
of a pull request; the lint reads no type, so any `types:` is a
|
|
filter. A key deeper than the event's own level (a dispatch's
|
|
inputs) is no filter. An event of a list or a scalar `on:` carries
|
|
none."""
|
|
lines = _lines(text)
|
|
out = {}
|
|
for i, ln in enumerate(lines):
|
|
m = _ON_RX.match(ln) if not _indent(ln) else None
|
|
if not m:
|
|
continue
|
|
if m.group(1).strip():
|
|
return out
|
|
at, event, sub = None, None, None
|
|
for child in lines[i + 1:]:
|
|
if not child.strip():
|
|
continue
|
|
ind = _indent(child)
|
|
if ind == 0:
|
|
break
|
|
at = ind if at is None else at
|
|
km = _KEY_RX.match(child)
|
|
if ind == at:
|
|
event, sub = None, None
|
|
if km:
|
|
event = km.group(2)
|
|
if km.group(3).strip():
|
|
out.setdefault(event, []).append("a value the lint does not read")
|
|
elif event is not None and ind > at:
|
|
sub = ind if sub is None else sub
|
|
if ind != sub:
|
|
continue
|
|
if _stray_key(child):
|
|
out.setdefault(event, []).append("a key the lint does not read")
|
|
elif km and km.group(2) in (_PULL_REQUEST_FILTER_KEYS if event == "pull_request" else _FILTER_KEYS):
|
|
out.setdefault(event, []).append(km.group(2))
|
|
return out
|
|
return out
|
|
|
|
|
|
def _needs(lines, i):
|
|
"""The job names the `needs:` key at lines[i] names: a flow list
|
|
(`[build, lint]`, on the key's line or continued on the lines under
|
|
it up to its `]`), one scalar, or a block list on the lines under it
|
|
(its items deeper than the key, or at the key's own indentation).
|
|
None for every other form, which the lint does not read: a name no
|
|
job has, so the need is never met. That is an empty `needs:` (`[]`,
|
|
or nothing under the key), a flow list a key or a shallower line
|
|
interrupts before its `]`, a line under the key that is no list
|
|
item, and list items at two indentations."""
|
|
key_at = _indent(lines[i])
|
|
val = _KEY_RX.match(lines[i]).group(3).strip()
|
|
if val.startswith("["):
|
|
for ln in lines[i + 1:]:
|
|
if "]" in val:
|
|
break
|
|
if not ln.strip():
|
|
continue
|
|
ind, body = _indent(ln), ln.strip()
|
|
if ind < key_at or (ind == key_at and not body.startswith("]")):
|
|
return None
|
|
val += " " + body
|
|
if "]" not in val:
|
|
return None
|
|
return [_scalar(x) for x in val[1:val.index("]")].split(",") if x.strip()] or None
|
|
if val:
|
|
return [_scalar(val)]
|
|
out, at = [], None
|
|
for ln in lines[i + 1:]:
|
|
if not ln.strip():
|
|
continue
|
|
ind, body = _indent(ln), ln.strip()
|
|
if ind < key_at or (ind == key_at and not body.startswith("- ")):
|
|
break
|
|
at = ind if at is None else at
|
|
if not body.startswith("- ") or ind != at:
|
|
return None
|
|
out.append(_scalar(body[2:]))
|
|
return out or None
|
|
|
|
|
|
def _needs_run(jobs):
|
|
"""Whether each job's `needs:` are met on every run of the workflow,
|
|
as "needs_run" on the job's dict: True when every job it needs runs
|
|
on every run (no `if:` other than the literal true, and its own
|
|
needs met, transitively), False when one does not, names no job of
|
|
the workflow, or is in a cycle with it, or when the `needs:` is a
|
|
form the lint does not read. The lint evaluates no condition, so a
|
|
job under `always()` counts as no job, as its steps do. A needed
|
|
job's `continue-on-error:` does not bear: its failure fails neither
|
|
the run nor the jobs after it."""
|
|
state = {}
|
|
|
|
def runs(name, trail):
|
|
if name in state:
|
|
return state[name]
|
|
job = jobs.get(name)
|
|
if job is None or name in trail:
|
|
return False
|
|
needs = job.get("needs", [])
|
|
met = needs is not None and all(runs(n, trail | {name}) for n in needs)
|
|
job["needs_run"] = met
|
|
state[name] = met and job.get("if", "true").lower() == "true"
|
|
return state[name]
|
|
for name in jobs:
|
|
runs(name, frozenset())
|
|
|
|
|
|
def _defaults(lines, i):
|
|
"""{"shell": its value} for the `defaults:` key at lines[i] when it
|
|
gives the run steps a shell:, {} when it gives none. A flow mapping
|
|
(at the key or at its run:) and a key with one stray quote under it
|
|
give None: a shell the lint does not read."""
|
|
if _KEY_RX.match(lines[i]).group(3).strip():
|
|
return {"shell": None}
|
|
out = {}
|
|
for ln in lines[i + 1:]:
|
|
if not ln.strip():
|
|
continue
|
|
if _indent(ln) <= _indent(lines[i]):
|
|
break
|
|
km = _KEY_RX.match(ln)
|
|
if _stray_key(ln) or (km and km.group(2) == "run" and km.group(3).strip()):
|
|
return {"shell": None}
|
|
if km and km.group(2) == "shell":
|
|
out["shell"] = _scalar(km.group(3))
|
|
return out
|
|
|
|
|
|
def workflow_steps(text):
|
|
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
|
|
command text: a literal block's lines one per line; a folded block,
|
|
or a scalar continued on the lines under its key, as one line),
|
|
"working-directory", "with" (a dict), "shell", "if" and
|
|
"continue-on-error" as written; "job": the "if" and
|
|
"continue-on-error" of the job the step is in, when it is in one,
|
|
its "needs" (_needs) and whether they are met on every run
|
|
("needs_run", _needs_run), and the "shell" its `defaults:` give; and
|
|
"workflow": the "shell" the
|
|
workflow's own `defaults:` give, when they give one (_defaults). YAML
|
|
read by its indentation, as far as these workflows use it; comments,
|
|
whole lines and from ' #' to the end of a line, are not read.
|
|
Anything outside a step (a trigger's paths, a job's name) is not a
|
|
step. A key with one stray quote (`if":`, `"run:`) at the top level,
|
|
at a job's name or a job's key, at a step's key, or as the first key of
|
|
a list item outside a step (a step's first key is on its dash line,
|
|
`- run": make`; a matrix entry is such an item; a list inside a step's
|
|
body is not read) is no key the lint reads: every step of the workflow
|
|
then carries "unread" and counts as no step."""
|
|
lines = _lines(text)
|
|
steps, i = [], 0
|
|
in_jobs, job_at, key_at, job, jobs = False, None, None, None, {}
|
|
stray, workflow = False, {}
|
|
while i < len(lines):
|
|
km = _KEY_RX.match(lines[i])
|
|
if km: # a mapping key: which job this is, and what the job itself says
|
|
ind, key = _indent(lines[i]), km.group(2)
|
|
if ind == 0:
|
|
in_jobs, job_at, key_at, job = key == "jobs", None, None, None
|
|
if key == "defaults":
|
|
workflow.update(_defaults(lines, i))
|
|
elif in_jobs and (job_at is None or ind == job_at):
|
|
job_at, key_at, job = ind, None, {}
|
|
jobs[key] = job
|
|
elif in_jobs and job_at is not None and ind > job_at and (key_at is None or ind == key_at):
|
|
key_at = ind
|
|
if key in _CONDITION_KEYS:
|
|
job[key] = _scalar(km.group(3))
|
|
elif key == "needs":
|
|
job["needs"] = _needs(lines, i)
|
|
elif key == "defaults":
|
|
job.update(_defaults(lines, i))
|
|
elif _stray_key(lines[i]):
|
|
ind = _indent(lines[i])
|
|
stray = stray or ind == 0 or (in_jobs and (job_at is None or ind == job_at or (
|
|
ind > job_at and (key_at is None or ind == key_at))))
|
|
# A list item's first key is on its dash line, where _STEP_RX reads no
|
|
# key with a stray quote: the dash and the spaces after it become spaces.
|
|
item = re.match(r"^\s*-\s+", lines[i])
|
|
if item and _stray_key(" " * item.end() + lines[i][item.end():]):
|
|
stray = True
|
|
m = _STEP_RX.match(lines[i])
|
|
if not m:
|
|
i += 1
|
|
continue
|
|
dash = len(m.group(1))
|
|
# The dash becomes a space, nothing realigned: the first key stays at
|
|
# the column the step's other keys use, however many spaces follow it.
|
|
body = [" " * (dash + 1) + lines[i][dash + 1:]]
|
|
i += 1
|
|
while i < len(lines) and (not lines[i].strip() or _indent(lines[i]) > dash):
|
|
body.append(lines[i])
|
|
i += 1
|
|
step, k = {}, 0
|
|
key_col = _indent(body[0])
|
|
while k < len(body):
|
|
km = _KEY_RX.match(body[k])
|
|
if not km or _indent(body[k]) != key_col:
|
|
stray = stray or (_indent(body[k]) == key_col and _stray_key(body[k]))
|
|
k += 1
|
|
continue
|
|
key, val = km.group(2), _scalar(km.group(3))
|
|
k += 1
|
|
child = []
|
|
while k < len(body) and (not body[k].strip() or _indent(body[k]) > key_col):
|
|
child.append(body[k])
|
|
k += 1
|
|
if key == "with":
|
|
step["with"] = {c.group(2): c.group(3).strip().strip("'\"")
|
|
for c in (_KEY_RX.match(x) for x in child) if c}
|
|
elif key == "run":
|
|
# YAML folds a folded block, and a plain or quoted scalar continued on the
|
|
# lines under its key, into one line where no blank or more indented line
|
|
# breaks it. Read as one line: where YAML keeps a break, the joined line
|
|
# counts no command that the shell's own lines would not.
|
|
text = [x.strip() for x in child if x.strip()]
|
|
if val[:1] == "|":
|
|
step["run"] = "\n".join(x.strip() for x in child)
|
|
elif val[:1] == ">":
|
|
step["run"] = " ".join(text)
|
|
else:
|
|
step["run"] = _scalar(" ".join([km.group(3).strip()] + text))
|
|
else:
|
|
step[key] = val
|
|
if job is not None:
|
|
step["job"] = job # the job's own dict: a job key written after its steps counts too
|
|
steps.append(step)
|
|
_needs_run(jobs)
|
|
for s in steps:
|
|
if stray:
|
|
s["unread"] = True
|
|
if workflow:
|
|
s["workflow"] = workflow
|
|
return steps
|
|
|
|
|
|
def _on_every_run(step):
|
|
"""Whether the lint counts a step as one that runs on every run of
|
|
its workflow and fails the run when it fails: neither the step nor
|
|
its job carries an `if:` other than the literal `true` or a
|
|
`continue-on-error:` other than the literal `false`, the job's
|
|
`needs:` are met on every run (_needs_run: GitHub skips a job whose
|
|
needed job did not run, unless the job says `always()`, which the
|
|
lint does not evaluate), and the workflow has no key the lint
|
|
cannot read ("unread"). The lint evaluates no condition, so a step
|
|
under `always()` or `success()` counts as no step too."""
|
|
if step.get("unread"):
|
|
return False
|
|
job = step.get("job") or {}
|
|
if not job.get("needs_run", True):
|
|
return False
|
|
for d in (step, job):
|
|
if d.get("if", "true").lower() != "true" or d.get("continue-on-error", "false").lower() != "false":
|
|
return False
|
|
return True
|
|
|
|
|
|
def checkout_path(steps, name, own):
|
|
"""Where a workflow checks the repository `name` out (its path, "" for
|
|
the workspace), or None when it does not: its own repository is the
|
|
checkout that names no repository, another one the checkout whose
|
|
repository is <owner>/<name>."""
|
|
for s in steps:
|
|
if not _on_every_run(s) or not s.get("uses", "").startswith("actions/checkout"):
|
|
continue
|
|
w = s.get("with", {})
|
|
repo = w.get("repository")
|
|
if (repo is None and own) or (repo is not None and repo.rsplit("/", 1)[-1].lower() == name.lower()):
|
|
return posixpath.normpath(w.get("path") or ".")
|
|
return None
|
|
|
|
|
|
# What only starts the real command, and the flags with which each one
|
|
# runs it. Any other flag (`command -v` and `sudo -l` print the command)
|
|
# leaves the prefix as the command.
|
|
_PREFIXES = {"sudo": ("-E", "-H", "-n"), "env": ("-i", "-"), "time": ("-p",), "command": ("-p",), "nice": ()}
|
|
_INTERPRETER_RX = re.compile(r"^(?:python[0-9.]*|bash|sh)$")
|
|
# The shell's operators, the longest first. A redirection keeps a command
|
|
# one command, and a pipe joins commands into a pipeline; any other
|
|
# operator makes a list of commands, puts one in the background, or opens
|
|
# a subshell or a here-document.
|
|
_OPERATORS = ("&>>", "&&", "||", ";;", ";&", "|&", ">>", "<<", ">&", "<&", "<>", ">|", "&>",
|
|
"|", "&", ";", "(", ")", "<", ">")
|
|
_REDIRECTIONS = frozenset((">", ">>", "<", ">&", "<&", "<>", ">|", "&>", "&>>"))
|
|
# A command word that opens or closes a compound command (an if, a case,
|
|
# a loop, a group, a function) or negates a status.
|
|
_RESERVED = frozenset(("if", "then", "elif", "else", "fi", "case", "esac", "for", "select", "while",
|
|
"until", "do", "done", "function", "coproc", "{", "}", "[[", "]]", "!"))
|
|
# A command after which the lint cannot tell what a later line runs, or
|
|
# whether a failure still ends the step.
|
|
_STOPS = frozenset(("exit", "return", "exec", "trap", "eval", "source", ".", "alias", "shopt",
|
|
"enable", "builtin", "hash", "break", "continue", "logout"))
|
|
# The `set` options the lint follows (errexit, pipefail) or knows to
|
|
# change neither; a `set` of any other option ends what it reads.
|
|
_SET_FLAGS = {"e": "errexit", "u": "nounset", "x": "xtrace", "v": "verbose"}
|
|
_SET_NAMES = frozenset(("errexit", "pipefail", "nounset", "xtrace", "verbose"))
|
|
|
|
|
|
def _part_end(line, i):
|
|
"""Where the part of a shell word that starts at line[i] ends: a
|
|
quoted string, an escaped character, an expansion ($(...), ${...},
|
|
`...`), or one character; -1 when it does not close on the line."""
|
|
n, c = len(line), line[i]
|
|
if c == "\\":
|
|
return i + 2 if i + 1 < n else -1
|
|
if c == "'":
|
|
j = line.find("'", i + 1)
|
|
return j + 1 if j > 0 else -1
|
|
if c in "\"`":
|
|
j = i + 1
|
|
while j < n and line[j] != c:
|
|
j = _part_end(line, j) if line[j] in "\\$" or (c == '"' and line[j] == "`") else j + 1
|
|
if j < 0:
|
|
return -1
|
|
return j + 1 if j < n else -1
|
|
if c == "$" and line[i + 1:i + 2] in ("(", "{"):
|
|
opener, depth, j = line[i + 1], 0, i + 1
|
|
while j < n:
|
|
if line[j] == opener:
|
|
depth, j = depth + 1, j + 1
|
|
elif line[j] == (")" if opener == "(" else "}"):
|
|
depth, j = depth - 1, j + 1
|
|
if not depth:
|
|
return j
|
|
elif line[j] in "\\'\"`$":
|
|
j = _part_end(line, j)
|
|
if j < 0:
|
|
return -1
|
|
else:
|
|
j += 1
|
|
return -1
|
|
return i + 1
|
|
|
|
|
|
def _shell_words(line):
|
|
"""One line of shell as the lint reads it: [(True, word) or (False,
|
|
operator)], a word with its quotes taken off and its expansions kept
|
|
as written; None when a quote or an expansion does not close on the
|
|
line."""
|
|
out, word, i = [], None, 0
|
|
while i < len(line):
|
|
c = line[i]
|
|
if c in " \t|&;()<>":
|
|
if word is not None:
|
|
out.append((True, word))
|
|
word = None
|
|
if c in " \t":
|
|
i += 1
|
|
else:
|
|
op = next(o for o in _OPERATORS if line.startswith(o, i))
|
|
out.append((False, op))
|
|
i += len(op)
|
|
continue
|
|
j = _part_end(line, i)
|
|
if j < 0:
|
|
return None
|
|
part = line[i:j]
|
|
if part[0] in "'\"":
|
|
part = part[1:-1]
|
|
elif part[0] == "\\":
|
|
part = part[1:]
|
|
word = (word or "") + part
|
|
i = j
|
|
if word is not None:
|
|
out.append((True, word))
|
|
return out
|
|
|
|
|
|
def _command(words):
|
|
"""A simple command's words with what only starts the real command
|
|
taken off: sudo, env, time, command and nice with the flags with which
|
|
they run it (_PREFIXES), and variable assignments."""
|
|
while words:
|
|
if words[0] in _PREFIXES:
|
|
rest = words[1:]
|
|
while rest and rest[0] in _PREFIXES[words[0]]:
|
|
rest = rest[1:]
|
|
if rest[:1] and rest[0].startswith("-"):
|
|
break
|
|
words = rest
|
|
elif re.match(r"^[A-Za-z_]\w*=", words[0]):
|
|
words = words[1:]
|
|
else:
|
|
break
|
|
return words
|
|
|
|
|
|
def _line_commands(tokens):
|
|
"""The commands of a line of shell (_shell_words) that is one simple
|
|
command, or a pipeline of them, and nothing else, each as its words
|
|
after _command, with its redirections taken off. None for any other
|
|
line: one that does not close, holds another operator (a list, a
|
|
background job, a subshell, a here-document) or a pipe at its end, or
|
|
has a command that is a reserved word or one of _STOPS."""
|
|
if tokens is None:
|
|
return None
|
|
cmds, words, k = [], [], 0
|
|
while k < len(tokens):
|
|
is_word, text = tokens[k]
|
|
if is_word:
|
|
words.append(text)
|
|
elif text in _REDIRECTIONS and k + 1 < len(tokens) and tokens[k + 1][0]:
|
|
k += 1 # the redirection's target
|
|
elif text == "|" and words:
|
|
cmds.append(words)
|
|
words = []
|
|
else:
|
|
return None
|
|
k += 1
|
|
if cmds and not words:
|
|
return None
|
|
cmds = [_command(c) for c in cmds + [words]]
|
|
return None if any(c[:1] and (c[0] in _RESERVED or c[0] in _STOPS) for c in cmds) else cmds
|
|
|
|
|
|
def _for_header(tokens):
|
|
"""The words of a line `for <name> in <words>; do` without its `; do`,
|
|
None for any other line."""
|
|
if (tokens and len(tokens) >= 5 and tokens[0] == (True, "for") and tokens[2] == (True, "in")
|
|
and tokens[-2:] == [(False, ";"), (True, "do")] and all(w for w, _t in tokens[:-2])):
|
|
return [t for _w, t in tokens[:-2]]
|
|
return None
|
|
|
|
|
|
def _set(args, opts):
|
|
"""Follow in opts the errexit and pipefail that a `set` command's
|
|
arguments set or clear. False when they name an option the lint does
|
|
not follow (noexec, onecmd, the listing of `set -o`)."""
|
|
k = 0
|
|
while k < len(args):
|
|
a, k = args[k], k + 1
|
|
if a in ("-", "--") or len(a) < 2 or a[0] not in "-+":
|
|
return True # the positional parameters follow
|
|
for ch in a[1:]:
|
|
if ch == "o":
|
|
name, k = (args[k] if k < len(args) else ""), k + 1
|
|
else:
|
|
name = _SET_FLAGS.get(ch)
|
|
if name not in _SET_NAMES:
|
|
return False
|
|
if name in opts:
|
|
opts[name] = a[0] == "-"
|
|
return True
|
|
|
|
|
|
def _shell(step):
|
|
"""The shell a step's run text runs under: the step's own shell:, else
|
|
its job's `defaults: run: shell:`, else the workflow's; "" for none,
|
|
which is bash -e (sh -e with no bash) on a Linux or macOS runner (the
|
|
lint does not read runs-on). None when any one of them is other than
|
|
bash or sh: the lint reads the text of no other shell."""
|
|
given = [d["shell"] for d in (step, step.get("job") or {}, step.get("workflow") or {}) if "shell" in d]
|
|
if any(g not in ("bash", "sh") for g in given):
|
|
return None
|
|
return given[0] if given else ""
|
|
|
|
|
|
def _run_commands(run, shell):
|
|
"""The commands of a step's run text that the lint counts, each as its
|
|
words (_line_commands): see workflow_runs."""
|
|
opts = {"errexit": True, "pipefail": shell == "bash"}
|
|
lines = [ln.strip() for ln in run.replace("\\\n", " ").split("\n") if ln.strip()]
|
|
out, k = [], 0
|
|
while k < len(lines):
|
|
tokens = _shell_words(lines[k])
|
|
k += 1
|
|
header = _for_header(tokens)
|
|
if header is not None: # the loop is read whole: its header, its body, its done
|
|
groups = [[header]]
|
|
while k < len(lines) and lines[k] != "done":
|
|
cmds = _line_commands(_shell_words(lines[k]))
|
|
if (cmds is None or any(c[:1] == ["set"] for c in cmds)
|
|
or (len(cmds) > 1 and not opts["pipefail"])):
|
|
return out
|
|
groups.append(cmds)
|
|
k += 1
|
|
if k == len(lines):
|
|
return out
|
|
k += 1
|
|
else:
|
|
cmds = _line_commands(tokens)
|
|
if cmds is None:
|
|
return out
|
|
if any(c[:1] == ["set"] for c in cmds):
|
|
if len(cmds) > 1 or not _set(cmds[0][1:], opts):
|
|
return out
|
|
continue
|
|
groups = [cmds]
|
|
if opts["errexit"]:
|
|
out.extend(c for cmds in groups if len(cmds) == 1 or opts["pipefail"] for c in cmds if c)
|
|
return out
|
|
|
|
|
|
def _segments_match(path, pattern):
|
|
"""A path against a path or a glob, one segment against one segment
|
|
('*' never crosses a '/')."""
|
|
import fnmatch
|
|
a, b = path.split("/"), pattern.split("/")
|
|
return len(a) == len(b) and all(fnmatch.fnmatchcase(x, y) for x, y in zip(a, b))
|
|
|
|
|
|
def _discovers(args, full, at):
|
|
"""Whether `python -m unittest discover ARGS` finds the test: its
|
|
directory is the start directory (-s, default the working directory)
|
|
and its file name matches the pattern (-p, default test*.py)."""
|
|
import fnmatch
|
|
start, pattern = ".", "test*.py"
|
|
for i, a in enumerate(args):
|
|
if a in ("-s", "--start-directory") and i + 1 < len(args):
|
|
start = args[i + 1]
|
|
elif a in ("-p", "--pattern") and i + 1 < len(args):
|
|
pattern = args[i + 1]
|
|
return posixpath.dirname(full) == at(start) and fnmatch.fnmatchcase(posixpath.basename(full), pattern)
|
|
|
|
|
|
def _unittest_names(args, full, at):
|
|
"""Whether `python -m unittest ARGS` (not discover) runs the test: an
|
|
argument is its file (tests/test_x.py), or a dotted name whose module
|
|
is its file (tests.test_x, tests.test_x.Case.test_one)."""
|
|
skip = False
|
|
for a in args:
|
|
if skip:
|
|
skip = False
|
|
continue
|
|
if a in ("-k",):
|
|
skip = True
|
|
continue
|
|
if a.startswith("-") or "$" in a:
|
|
continue
|
|
if a.endswith(".py"):
|
|
if at(a) == full:
|
|
return True
|
|
continue
|
|
parts = a.split(".")
|
|
if any(at("/".join(parts[:k]) + ".py") == full for k in range(len(parts), 0, -1)):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _pytest_runs(args, full, at):
|
|
"""Whether pytest ARGS collects the test: named, or its directory given
|
|
and its file name one pytest collects (test_*.py, *_test.py)."""
|
|
import fnmatch
|
|
name = posixpath.basename(full)
|
|
collected = fnmatch.fnmatchcase(name, "test_*.py") or fnmatch.fnmatchcase(name, "*_test.py")
|
|
for a in args:
|
|
if a.startswith("-") or "$" in a:
|
|
continue
|
|
if at(a) == full or (collected and at(a) == posixpath.dirname(full)):
|
|
return True
|
|
return False
|
|
|
|
|
|
def workflow_runs(steps, test_path):
|
|
"""Whether a step of the workflow runs the test at test_path (its path
|
|
in the workspace: the checkout's path joined to the test's path in its
|
|
repository) so that a failure of the test fails the step.
|
|
|
|
Only a step that _on_every_run accepts counts: the lint evaluates no
|
|
condition, so a step under an `if:` other than the literal true, or
|
|
with a `continue-on-error:` other than the literal false, on the step
|
|
or on its job, counts as no step. So does a step whose shell is other
|
|
than bash or sh (_shell: its own shell:, or a `defaults: run: shell:`
|
|
of its job or of the workflow).
|
|
|
|
The lint reads a step's run text line by line as bash -e or sh -e
|
|
runs it, a backslash at the end of a line joining the next one
|
|
(_run_commands). A command counts on a line that holds it alone, with
|
|
its redirections, or in a pipeline under pipefail (shell: bash, or a
|
|
`set -o pipefail` on an earlier line), and only while errexit holds
|
|
(`set +e` clears it). A pipeline with no pipefail does not count. The
|
|
lint stops at a line that holds any other operator (`||`, `&&`, `;`,
|
|
`&`, a subshell, a here-document), whose command opens a compound
|
|
command (if, case, while, until, a group, a function) or negates a
|
|
status (!), whose command is one of _STOPS (exit, exec, trap, eval,
|
|
source and the like), or that sets an option it does not follow: no
|
|
command on that line or after it counts. A for loop counts in one
|
|
form: `for <name> in <words>; do` on its own line, body lines that
|
|
count, and `done` on its own line.
|
|
|
|
Each path is resolved against the step's working-directory. A command
|
|
runs the test when it is the test (a script run directly, or a C
|
|
test's built binary, <dir>/<stem>); the script an interpreter runs
|
|
(python, bash, sh), a glob matching the whole path; a for loop's list
|
|
over the test; python -m unittest given the test's file or a dotted
|
|
name whose module is that file (tests.test_x,
|
|
tests.test_x.Case.test_one); unittest discover from the test's own
|
|
directory with a pattern its name matches; pytest on the test or its
|
|
own directory. An argument to anything else (echo, a build, an
|
|
install) does not run the test."""
|
|
full = posixpath.normpath(test_path)
|
|
stem, ext = posixpath.splitext(posixpath.basename(full))
|
|
compiled = ext in (".c", ".cc", ".cpp")
|
|
for s in steps:
|
|
run, shell = s.get("run"), _shell(s)
|
|
if not run or shell is None or not _on_every_run(s):
|
|
continue
|
|
wd = s.get("working-directory") or "."
|
|
|
|
def at(p):
|
|
return posixpath.normpath(posixpath.join(wd, p))
|
|
|
|
def names(p):
|
|
return "$" not in p and _segments_match(full, at(p))
|
|
for words in _run_commands(run, shell):
|
|
cmd, args = words[0], words[1:]
|
|
if cmd == "for" and "in" in args:
|
|
if any(names(p) for p in args[args.index("in") + 1:]):
|
|
return True
|
|
elif _INTERPRETER_RX.match(posixpath.basename(cmd)):
|
|
if "-m" in args:
|
|
i = args.index("-m")
|
|
mod, rest = (args[i + 1], args[i + 2:]) if i + 1 < len(args) else ("", [])
|
|
if mod == "unittest" and rest[:1] == ["discover"]:
|
|
if _discovers(rest[1:], full, at):
|
|
return True
|
|
elif (mod == "unittest" and _unittest_names(rest, full, at)) or \
|
|
(mod == "pytest" and _pytest_runs(rest, full, at)):
|
|
return True
|
|
else:
|
|
script = next((a for a in args if not a.startswith("-")), None)
|
|
if script and names(script):
|
|
return True
|
|
elif posixpath.basename(cmd) == "pytest":
|
|
if _pytest_runs(args, full, at):
|
|
return True
|
|
elif names(cmd) or (compiled and "/" in cmd and posixpath.basename(cmd) == stem):
|
|
return True
|
|
return False
|
|
|
|
|
|
def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCES):
|
|
"""HOST_TESTED rows that prove nothing: a glob that selects no
|
|
behavioral file of its component (the same defect as an empty covers
|
|
entry), a host test or a workflow that does not exist, a workflow that
|
|
cannot be read where it should be, one whose own `on:` names neither
|
|
push nor pull_request as the lint reads it (workflow_triggers: a
|
|
dispatch, a call or a schedule alone, a call not followed to the
|
|
workflow that calls it, and a flow mapping, which names no event),
|
|
one whose every push and pull_request carries a filter
|
|
(workflow_filters: a branches, tags or paths filter, or a types
|
|
filter on the pull_request, which the lint does not read, so the
|
|
workflow runs on not every change of the covered files, or on not
|
|
every branch; one push or pull_request with no filter makes the
|
|
workflow run on every change),
|
|
one that does not check out the test's repository, or one with no
|
|
step that runs the test on every run (workflow_runs). Whether a
|
|
workflow runs its test is not checked where it cannot be read (see
|
|
read_source). Returns [(component, glob, host test, why)]."""
|
|
out = []
|
|
for comp, pat, ref, ci in host:
|
|
files = manifest.files(comp)
|
|
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
|
|
out.append((comp, pat, ref, "selects nothing"))
|
|
why = _ref_problem(manifest, ref, "host test", root) or _ref_problem(manifest, ci, "workflow", root)
|
|
if why:
|
|
out.append((comp, pat, ref, why))
|
|
continue
|
|
text, why = read_source(manifest, ci, root, sources)
|
|
if why:
|
|
out.append((comp, pat, ref, "%s %s" % (ci, why)))
|
|
continue
|
|
if text is None:
|
|
continue
|
|
events = workflow_triggers(text) & {"push", "pull_request"}
|
|
if not events:
|
|
out.append((comp, pat, ref, "%s: the lint reads no push or pull_request in its own on:" % ci))
|
|
continue
|
|
filters = workflow_filters(text)
|
|
if all(e in filters for e in events):
|
|
out.append((comp, pat, ref, "%s: every push or pull_request in its own on: carries a filter (%s)" % (
|
|
ci, "; ".join("%s: %s" % (e, ", ".join(filters[e])) for e in sorted(events)))))
|
|
continue
|
|
steps = workflow_steps(text)
|
|
test_repo, _sep, test_path = ref.partition(":")
|
|
prefix = checkout_path(steps, repo_name(manifest, test_repo), test_repo == ci.partition(":")[0])
|
|
if prefix is None:
|
|
out.append((comp, pat, ref, "%s does not check out %s" % (ci, test_repo)))
|
|
elif not workflow_runs(steps, posixpath.join(prefix, test_path)):
|
|
out.append((comp, pat, ref, "%s does not run it" % ci))
|
|
return out
|
|
|
|
|
|
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL, host=HOST_TESTED):
|
|
"""Which manifest paths neither a test nor a host test covers.
|
|
|
|
tests: iterable with .covers. allow: iterable of (component, glob)
|
|
that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is
|
|
never allowed away, as it is never taken out of a fingerprint. host:
|
|
the HOST_TESTED rows, whose paths count as covered. Returns
|
|
{component: [uncovered paths]} for the non-dev-only components.
|
|
"""
|
|
covered = {}
|
|
for t in tests:
|
|
for comp, pat in t.covers:
|
|
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
|
for comp, pat, _ref, _ci in host:
|
|
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
|
allowed = {}
|
|
for comp, pat in allow:
|
|
allowed.setdefault(comp, []).append(glob_to_regex(pat))
|
|
report = {}
|
|
for comp in manifest.component_names():
|
|
if comp in DEV_ONLY_COMPONENTS:
|
|
continue
|
|
rxs = covered.get(comp, [])
|
|
free = allowed.get(comp, []) + allowed.get("*", [])
|
|
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
|
|
missing = []
|
|
for p, _b in manifest.files(comp):
|
|
if any(rx.match(p) for rx in rxs):
|
|
continue
|
|
if any(rx.match(p) for rx in free) and not any(rx.match(p) for rx in behavior):
|
|
continue
|
|
missing.append(p)
|
|
if missing:
|
|
report[comp] = sorted(missing)
|
|
return report
|