Files
forgefirm/forgetest/forgetest/manifest.py
T
ScottW514 70e13bdd88 Catalog: setup.mirror and logs.tree-tail-export leave; the catalog's address in forgetest-ci
Two catalog tests leave for the host proofs that carry them; the catalog
goes from 83 to 81 (81 @test in forgetest/forgetest/suite/).

- setup.mirror, for forgectrl's tests/host_ext.sh (0086e65): with a
  temporary account and two logins, a package's check that session A
  starts is owned and A's, owned and not B's, and the token's with no
  session; B's abort is 409 and the run goes on; B's takeover makes it
  B's; B's abort ends it aborted. Its section header, "the lifecycle",
  held only it. setup.check-sensors and setup.account-login stay.
- logs.tree-tail-export, for forgectrl's tests/host_guard.sh (0086e65):
  GET /logs names forgectrl, the tail carries a line just written, an
  unknown logger is 404, and the export carries README.txt, the
  installer's directory (a probe without its MAC or IP address, and
  install.log) and system/extensions.json, with neither the panel token
  nor the camera key in any member. _LOG_COVERS, the _PROBE_ constants,
  and the gzip, io, and tarfile imports, which only it read, go, and so
  does logs.routing's requires on it. logs.routing stays.

forgectrl.lease stays. Its proof passed (forgectrl's tests/host_lease.sh,
0086e65), but its deletion left forgectrl src/events.c and src/events.h
uncovered, a path outside the two this chunk may row, so it does not
land.

The coverage lint then named forgectrl src/sanitize.c, src/sanitize.h,
src/extpkg.c and src/extpkg.h, and nothing else. Two HOST_TESTED rows
take them, to forgectrl:tests/sanitize_test.c and
forgectrl:tests/extpkg_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs (./build/sanitize_test, ./build/extpkg_test).

forgetest-ci, after the tree-manifest step: the key comparison moves
there unchanged, and a step beside it checks the catalog's address.
From forgectrl-pin.inc's SRCREV it reads src/extpkg.h out of the
manifest cache (git show) and takes EXTPKG_INDEX_URL; from the
checked-out catalog's tools/publish.py it takes the basename of the file
passed to gh release create; the define must be exactly
https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/
and that basename. A missing pin, define, or basename fails. Nothing
requests the address or fetches the asset. The step's run text, taken
from the workflow, passes from the tree root (c274f19's address and
index-1.ffi) and on a /tmp copy of the tree; controls on that copy,
publish.py passing index-2.ffi and extpkg.h naming another repository:
each fails the comparison. The key step's text is unchanged and was not
run again.

test_manifest.py (34) passes. The coverage lint on the tree manifest
finds no uncovered path and no host-tested row problem across the 81
tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes reports nothing on setup.py, logs.py or manifest.py.
The full forgetest suite was not run.

Fingerprints, measured against the parent: 23 tests move with
setup.py's and logs.py's shared text: the 5 left in setup.py, the 12 in
the modules that read setup.py (setup_dark.py's 9, setup_burns.py's 2,
bedsize.py's setup.check-envelope), the 5 left in exthost.py, which
reads it too, and logs.routing. The pending pin bumps from forgectrl
c274f19 and forgeext 2d0391e already owe 21 of them, and
exthost.platform has been owed since 30c0a05. This adds one:
setup.machine-name, which no pinned change selects. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 17:09:32 -04:00

627 lines
26 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: 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"),
]
# 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))
_KEY_RX = re.compile(r"^(\s*)([\w-]+)\s*:\s*(.*)$")
def _indent(line):
return len(line) - len(line.lstrip(" "))
def workflow_steps(text):
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
command text, a block's lines joined), "working-directory", and "with"
(a dict). 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."""
lines = []
for ln in text.splitlines():
if ln.lstrip().startswith("#"):
continue
lines.append(re.sub(r"\s+#.*$", "", ln).rstrip())
steps, i = [], 0
while i < len(lines):
m = _STEP_RX.match(lines[i])
if not m:
i += 1
continue
dash = len(m.group(1))
body = [" " * (dash + 2) + lines[i][dash + 1:].lstrip()]
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:
k += 1
continue
key, val = km.group(2), km.group(3).strip()
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
val = val[1:-1] # a quoted scalar, not a quote inside a command
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":
step["run"] = "\n".join(x.strip() for x in child) if val[:1] in ("|", ">") else val
else:
step[key] = val
steps.append(step)
return steps
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 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
_SPLIT_RX = re.compile(r"&&|\|\||[;|\n]|(?:^|(?<=\s))(?:then|do|else)(?=\s|$)")
_PREFIXES = ("sudo", "env", "time", "exec", "command", "nice")
_INTERPRETER_RX = re.compile(r"^(?:python[0-9.]*|bash|sh)$")
def _commands(run):
"""The simple commands of a step's run text, each as its words, with
what only starts the real command taken off: sudo and its flags, env,
time, exec, and variable assignments."""
import shlex
out = []
for part in _SPLIT_RX.split(run.replace("\\\n", " ")):
try:
words = shlex.split(part)
except ValueError:
words = part.split()
while words:
if words[0] in _PREFIXES:
words = words[1:]
while words and words[0].startswith("-"):
words = words[1:]
elif re.match(r"^[A-Za-z_]\w*=", words[0]):
words = words[1:]
else:
break
if words:
out.append(words)
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). Only what a step's run commands execute counts, each path
resolved against the step's working-directory: the command itself
(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 = s.get("run")
if not run:
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 _commands(run):
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 that does not check out the
test's repository, or one with no step that runs the test. 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
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