Some checks failed
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Has been cancelled
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Has been cancelled
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Has been cancelled
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Has been cancelled
wai-audit collector and player integration / collector · linux (push) Has been cancelled
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Has been cancelled
WAI operation catalogue / the catalogue and every door · linux (push) Has been cancelled
wai-fleet service / service · linux (push) Has been cancelled
wai-fleet service / service · macos (push) Has been cancelled
wai-key-proxy / proxy · linux (push) Has been cancelled
wai-ledger service / does this change touch it (push) Has been cancelled
wai-ledger service / service · linux (push) Has been cancelled
wai-ledger service / service · macos (push) Has been cancelled
wai-quantum for JavaScript / does this change touch it (push) Has been cancelled
wai-quantum for JavaScript / javascript · linux (push) Has been cancelled
wai-quantum energy-landscape jobs / does this change touch it (push) Has been cancelled
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Has been cancelled
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Has been cancelled
wai-quantum energy-landscape jobs / the same bits · macos (push) Has been cancelled
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Has been cancelled
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Has been cancelled
wai-quantum studio and desktop / does this change touch it (push) Has been cancelled
wai-quantum studio and desktop / studio · browser (push) Has been cancelled
wai-quantum studio and desktop / desktop app · macos (push) Has been cancelled
wai-quantum studio and desktop / desktop app · linux (push) Has been cancelled
wai-quantum studio and desktop / desktop app · windows (push) Has been cancelled
wai-sink-android native sink library / does this change touch it (push) Has been cancelled
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Has been cancelled
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Has been cancelled
wai-sink-swift native sink package / does this change touch it (push) Has been cancelled
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Has been cancelled
WAI Studio / does this change touch it (push) Has been cancelled
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Has been cancelled
WAI Studio / Studio in Chromium and Firefox · macos (push) Has been cancelled
WAI Studio / Studio in WebKit · linux (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Has been cancelled
wai-app and Create / wai-app · does this change touch it (push) Has been cancelled
wai-audit collector and player integration / does this change touch it (push) Has been cancelled
WAI operation catalogue / does this change touch it (push) Has been cancelled
joule-moq-room room service / joule-moq-room · does this change touch it (push) Has been cancelled
wai-key-proxy / does this change touch it (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Has been cancelled
wai-fleet service / does this change touch it (push) Has been cancelled
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Successful in 51s
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (push) Has been cancelled
joule-code conformance gate / joule-moq-ingest, every target (push) Has been cancelled
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 18s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 1m44s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 6m32s
- relay, a kind of its own, with the suite moqt in the live area: the new corpus moqt-conformance/cases.json lists the eight interop test cases and wai-receipts, each with the drafts it is a test at, so a record names its corpus by digest. joule-moq-interop runs each case against the relay's moqt:// or https:// URL at --draft 22, 18 or 16, and its TAP version 14 is read as the cases: ok passes; not ok, TODO, no point, exit 127 or a timeout fails; a skip passes only at a draft the case lists no test for. The client is found as wai dev finds its daemons (WAI_MOQ_INTEROP_BIN, beside wai, PATH). The record's adapter is command, its impl the client's command line with the URL and draft, its files the client. - joule-moq-interop closes every session's endpoint and waits it idle after each test: run a test a process, it exited before the CONNECTION_CLOSE left, the relay kept the dead publisher's namespace, and subscribe-before-announce and announce-subscribe failed against wai-relay at every draft. Now every case passes at 22, 18 and 16 over QUIC and at 22 over WebTransport. - verifier pq: pq-conformance case by case, each case given as the corpus with it alone; its independent verifier pq_verify.py now has its verdicts file in its # independent: line. - wai conformance gen --suite <corpus>[,...] [--verify] [--bin-dir <dir>] (conformance.generate, maintainer toolset): build.rs reads .github/wai-conformance/corpora/*.txt into the table of generators, and a corpus file whose dir: names no corpus, or whose bin: is no [[bin]] of wai-rs, fails the build. The files gain gen: where a tool's generator is not its gen mode (pin, staged-measure; oer: none). One cargo build serves every tool a run names. - wai-breaking.yml runs the new tests with pq_signatures, pq_verify.py through wai conform beside the claim, JWP and signer verifiers, and the reference relay at each draft, every record admitted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1417 lines
67 KiB
Python
Executable file
1417 lines
67 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""wai-conformance-lists.py: the lists wai-conformance.yml runs, derived from the files that
|
|
own them, so a change adds a file instead of editing the workflow's shared lists.
|
|
|
|
Each list below used to be kept by hand in the workflow, and every new module of WAI appended
|
|
to it, so any two branches that added one conflicted. Each is now read at run time from its
|
|
source:
|
|
|
|
.github/wai-conformance/verifiers/*.sh one file a step of the independent verifiers: its
|
|
shell body, and a header of `# key: value` lines that
|
|
gives its features, binaries, paths and order. From
|
|
them: WAI_FEATURES, WAI_PQ_FEATURES and
|
|
WAI_RELEASE_FEATURES, the binaries and tests the job
|
|
builds first, the steps it runs, the feature lists the
|
|
features-alone job checks, and the paths the workflow's
|
|
gate runs on.
|
|
.github/wai-conformance/corpora/*.txt one file a cargo-class corpus: its tool and directory.
|
|
The cargo-classes corpus loop runs them, with the
|
|
features their tools require (Cargo.toml) and the
|
|
step's own. Its header: corpus, order, bin, dir, and
|
|
`gen:` where the tool's modes that write the corpus
|
|
are not its `gen` (`none`: it writes nothing).
|
|
`wai conformance gen` runs them (wai-rs/build.rs reads
|
|
the files, and fails on a dir that names no corpus).
|
|
wai/wai-rs/tests/*.rs a test file whose line `// wai-conformance: <tag>...`
|
|
names a tag is run by the steps that take that tag:
|
|
int-kernels (both profiles), int-kernels-release (the
|
|
release profile only), int-kernels-amd (the AVX-512
|
|
host). Their features are the tests' required-features
|
|
and the step's own.
|
|
|
|
A verifier file's header (the leading `# key: value` lines; the first other line ends it):
|
|
|
|
step its name in the log (required)
|
|
order an integer: steps run in ascending order, a tie by file name (required)
|
|
features feature lists, space-separated, each comma-joined: each compiles alone in
|
|
the features-alone job, and every feature in them joins the build's list
|
|
alone the lists features-alone checks instead of `features`; `-` for none
|
|
build union (the default: WAI_FEATURES) or pq (WAI_PQ_FEATURES)
|
|
release-features features of the optimised build (WAI_RELEASE_FEATURES)
|
|
bins, pq-bins binaries the job builds first and copies to $RUNNER_TEMP/wai-bins/union or
|
|
/pq, beside every `wai-bins/union/NAME` and `wai-bins/pq/NAME` the body names
|
|
paths globs whose change runs the workflow (each under one of its `paths:` globs)
|
|
working-directory where the body runs, from the repository root (default wai)
|
|
independent one line an entry: a corpus's independent verifier, which the job runs with
|
|
every other in one step, `wai conform --independent`, and never in a body:
|
|
`<name> <area> [needs=<packages>] [verdicts=<file>] [dir=<dir>] python3 [-I]
|
|
<script> <args>`, the command line run in wai/ as the corpus's README gives
|
|
it. `wai` builds its table of independent verifiers from these lines
|
|
(wai-rs/build.rs). A file whose header holds entries and that has no body is a
|
|
declaration: its verifiers run in the union alone, and it is no step of its own.
|
|
|
|
The tests the job builds first are the `--test NAME` of the body's lines that build with
|
|
"$WAI_FEATURES", and with "$WAI_RELEASE_FEATURES" for the optimised build.
|
|
|
|
Commands (the workflow runs the first group):
|
|
|
|
env WAI_FEATURES=..., WAI_PQ_FEATURES=..., WAI_RELEASE_FEATURES=...
|
|
prebuild the shell script that builds the binaries and tests first
|
|
run [--only SLUG]... every verifier step in order, each in a log group; the first that
|
|
fails stops the run with its exit status (a declaration has no step)
|
|
independent the independent verifiers' entries, in order: `name area command` a line
|
|
needs WAI [--suite A,B] the union run by the `wai` at WAI on an interpreter with no packages, or
|
|
the entries --suite names: an entry that declares `needs=` must fail
|
|
there and one that declares none must pass, so what a failed verifier
|
|
is told it lacks is what it lacks
|
|
alone the feature lists features-alone checks, one a line
|
|
paths the paths the verifier files bring to the gate, one a line
|
|
tests TAG... `--test NAME` for every test file that carries one of the tags
|
|
features TAG... [--extra-features A,B]
|
|
the tests' required-features with A,B, comma-joined
|
|
test-tagged TAG... [--extra-features A,B] -- ARGS...
|
|
cargo test ARGS --features <features> <tests>, printed, then run
|
|
corpora [--verify] [--extra-features A,B]
|
|
the corpora, `name bin dir` a line; with --verify, run each tool's
|
|
verify over its corpus (cargo run), each in a log group
|
|
dry-run [--ref REF] every list and every step the workflow would run, in a stable form;
|
|
of the tree at REF instead with --ref, read from its files or, before
|
|
they existed, from the lists its workflow kept by hand. It ends with
|
|
the cargo command lines of the steps outside the verifiers' job that
|
|
take derived lists (the integer kernels' two steps, the corpus step,
|
|
int-kernels-amd, the features-alone loop), read from the workflow's own
|
|
step text and evaluated, each in one form (flags and features
|
|
sorted, each target once), and per shell family when the unix legs
|
|
and the Windows leg differ. A reviewer diffs two dry-runs: the same
|
|
lines are the same builds and tests.
|
|
baseline --ref REF the lists REF's workflow kept by hand, as JSON
|
|
check every file above is well formed and names what exists, the corpus
|
|
union's step holds the rules union_findings lists (no way of
|
|
passing a failed union that has been planted there passes it), and
|
|
wai-breaking.yml starts on a change to what the table's tests read
|
|
selftest [--exact] check, then every list derived here holds every entry of
|
|
.github/wai-conformance/baseline.json (the hand lists of the commit
|
|
that moved them out), and every step it names still runs; --exact
|
|
requires the lists equal, and each step the same body and directory
|
|
as the hand list's step of its name, in the same order (a step or a
|
|
corpus the tree adds is named as an addition)
|
|
|
|
Standard library only; runs on the Python 3.9 the runner images carry. --root DIR reads
|
|
another tree.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
VERIFIERS = ".github/wai-conformance/verifiers"
|
|
CORPORA = ".github/wai-conformance/corpora"
|
|
BASELINE = ".github/wai-conformance/baseline.json"
|
|
WORKFLOW = ".github/workflows/wai-conformance.yml"
|
|
CRATE = "wai/wai-rs"
|
|
TAGS = ("int-kernels", "int-kernels-release", "int-kernels-amd")
|
|
|
|
VERIFIER_KEYS = {
|
|
"step": "one", "order": "one", "features": "many", "alone": "many", "build": "one",
|
|
"release-features": "many", "bins": "many", "pq-bins": "many", "paths": "many",
|
|
"working-directory": "one", "independent": "lines",
|
|
}
|
|
CORPUS_KEYS = {"corpus": "one", "order": "one", "bin": "one", "dir": "one", "gen": "one"}
|
|
# `gen:` alone may be left out: the tool's generator is then its `gen` mode (`wai conformance gen`).
|
|
CORPUS_REQUIRED = ("corpus", "order", "bin", "dir")
|
|
HEADER = re.compile(r"^# ([a-z][a-z-]*): ?(.*)$")
|
|
MARKER = re.compile(r"^//\s*wai-conformance:\s*(.*?)\s*$", re.M)
|
|
|
|
|
|
class Bad(Exception):
|
|
pass
|
|
|
|
|
|
def uniq(xs):
|
|
out, seen = [], set()
|
|
for x in xs:
|
|
if x not in seen:
|
|
seen.add(x)
|
|
out.append(x)
|
|
return out
|
|
|
|
|
|
def gate_module(root):
|
|
"""scripts/pr-touches-paths.py, imported: the gate's own reading of paths and headers."""
|
|
path = os.path.join(root, "scripts", "pr-touches-paths.py")
|
|
if not os.path.isfile(path):
|
|
path = os.path.join(HERE, "pr-touches-paths.py")
|
|
spec = importlib.util.spec_from_file_location("pr_touches_paths", path)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
# ---------------------------------------------------------------------------- the files
|
|
|
|
|
|
def read_header(path, keys, label):
|
|
"""(header, body, errors) of a file whose leading `# key: value` lines are its header;
|
|
`label` names it in the errors. The body is what follows the leading comment block
|
|
(header and prose)."""
|
|
with open(path, encoding="utf-8") as fh:
|
|
lines = fh.read().split("\n")
|
|
head, errs, i = {}, [], 0
|
|
while i < len(lines):
|
|
m = HEADER.match(lines[i])
|
|
if not m:
|
|
break
|
|
k, v = m.group(1), m.group(2).strip()
|
|
if k not in keys:
|
|
errs.append(f"{label}: line {i + 1}: `{k}` is not a key of this file ({', '.join(sorted(keys))})")
|
|
elif keys[k] == "one":
|
|
if k in head:
|
|
errs.append(f"{label}: line {i + 1}: `{k}` is given twice")
|
|
head[k] = v
|
|
elif keys[k] == "lines":
|
|
head.setdefault(k, []).append(v)
|
|
else:
|
|
head.setdefault(k, []).extend(v.split())
|
|
i += 1
|
|
while i < len(lines) and lines[i].startswith("#"):
|
|
i += 1
|
|
body = "\n".join(lines[i:]).rstrip("\n")
|
|
return head, body, errs
|
|
|
|
|
|
AREAS = ("trust", "media", "delivery", "live")
|
|
ENTRY_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
|
|
|
def independent_entry(line):
|
|
"""(entry, None) of an `independent:` line, or (None, why it is not one). The same reading
|
|
as wai-rs/src/bin/wai_cli/dx/independent_table.rs, which build.rs runs."""
|
|
w = line.split()
|
|
if len(w) < 4:
|
|
return None, "an entry is `<name> <area> [key=value]... python3 [-I] <script> <args>`"
|
|
name, area, rest = w[0], w[1], w[2:]
|
|
if not ENTRY_NAME.match(name) or name in AREAS:
|
|
return None, f"the name `{name}` is lowercase words joined by hyphens, and no area's"
|
|
if area not in AREAS:
|
|
return None, f"the area `{area}` is none of {', '.join(AREAS)}"
|
|
opts = {}
|
|
while rest and rest[0] != "python3":
|
|
k, eq, v = rest[0].partition("=")
|
|
if not eq or k not in ("needs", "verdicts", "dir") or not v or k in opts:
|
|
return None, f"`{rest[0]}`: an option is needs=, verdicts= or dir=, each once, before `python3`"
|
|
opts[k] = v
|
|
rest = rest[1:]
|
|
if not rest:
|
|
return None, "no command line: it starts with `python3`"
|
|
rest = rest[1:]
|
|
isolated = bool(rest) and rest[0] == "-I"
|
|
if isolated:
|
|
rest = rest[1:]
|
|
if not rest:
|
|
return None, "the command line names no script"
|
|
return {"name": name, "area": area, "needs": opts.get("needs", "").split(",") if "needs" in opts else [],
|
|
"verdicts": opts.get("verdicts"), "dir": opts.get("dir"), "isolated": isolated,
|
|
"script": rest[0], "args": rest[1:],
|
|
"command": " ".join(["python3"] + (["-I"] if isolated else []) + rest)}, None
|
|
|
|
|
|
def entry_dir(e, wai):
|
|
"""The corpus directory of an entry: its `dir=`, or the first argument that names a
|
|
directory under wai/, or the directory of the first that names a file."""
|
|
if e["dir"]:
|
|
return e["dir"]
|
|
for a in e["args"]:
|
|
if a.startswith("-"):
|
|
continue
|
|
p = os.path.join(wai, a)
|
|
if os.path.isdir(p):
|
|
return a
|
|
if os.path.isfile(p):
|
|
return posix_dirname(a)
|
|
return None
|
|
|
|
|
|
def posix_dirname(p):
|
|
return p.rsplit("/", 1)[0] if "/" in p else ""
|
|
|
|
|
|
def runs_entry(line, e):
|
|
"""Whether a body's line runs an entry's command line itself (from wai/ or the root)."""
|
|
w = line.strip().split()
|
|
if not w or not w[0].startswith("python"):
|
|
return False
|
|
w = w[1:]
|
|
if w and w[0] == "-I":
|
|
w = w[1:]
|
|
norm = lambda x: x[4:] if x.startswith("wai/") else x
|
|
if not w or norm(w[0]) != e["script"]:
|
|
return False
|
|
args = [norm(x) for x in w[1:]]
|
|
return args[:len(e["args"])] == e["args"] and (len(args) == len(e["args"]) or args[len(e["args"])] in (">", "|", ">>", "2>&1", "&&", ";"))
|
|
|
|
|
|
def joined(body):
|
|
"""The body's lines with `\\` continuations joined."""
|
|
return re.sub(r"\\\n\s*", " ", body).split("\n")
|
|
|
|
|
|
def verifiers(root):
|
|
d = os.path.join(root, VERIFIERS)
|
|
if not os.path.isdir(d):
|
|
raise Bad(f"{VERIFIERS}: no such directory")
|
|
mods, errs = [], []
|
|
for f in sorted(os.listdir(d)):
|
|
if not f.endswith(".sh"):
|
|
continue
|
|
rel = f"{VERIFIERS}/{f}"
|
|
h, body, e = read_header(os.path.join(d, f), VERIFIER_KEYS, rel)
|
|
errs += e
|
|
for k in ("step", "order"):
|
|
if k not in h:
|
|
errs.append(f"{rel}: no `{k}:` line in its header")
|
|
try:
|
|
order = int(h.get("order", "0"))
|
|
except ValueError:
|
|
errs.append(f"{rel}: order `{h.get('order')}` is not an integer")
|
|
order = 0
|
|
build = h.get("build", "union")
|
|
if build not in ("union", "pq"):
|
|
errs.append(f"{rel}: build `{build}` is neither union nor pq")
|
|
alone = h.get("alone")
|
|
if alone == ["-"]:
|
|
alone = []
|
|
elif alone is not None and "-" in alone:
|
|
errs.append(f"{rel}: alone `-` stands alone")
|
|
entries = []
|
|
for line in h.get("independent", []):
|
|
e, why = independent_entry(line)
|
|
if why:
|
|
errs.append(f"{rel}: independent `{line}`: {why}")
|
|
else:
|
|
entries.append(e)
|
|
if not body.strip() and not entries:
|
|
errs.append(f"{rel}: no body: the step would run nothing")
|
|
mods.append({
|
|
"slug": f[:-3], "file": rel, "step": h.get("step", ""), "order": order,
|
|
"features": h.get("features", []), "alone": alone, "build": build,
|
|
"release": h.get("release-features", []), "bins": h.get("bins", []),
|
|
"pq_bins": h.get("pq-bins", []), "paths": h.get("paths", []),
|
|
"wd": h.get("working-directory", "wai"), "body": body,
|
|
"independent": entries, "declaration": not body.strip(),
|
|
})
|
|
mods.sort(key=lambda m: (m["order"], m["slug"]))
|
|
seen = {}
|
|
for m in mods:
|
|
if m["step"] and m["step"] in seen:
|
|
errs.append(f"{m['file']}: step `{m['step']}` is also {seen[m['step']]}'s")
|
|
seen.setdefault(m["step"], m["file"])
|
|
return mods, errs
|
|
|
|
|
|
def corpora(root):
|
|
d = os.path.join(root, CORPORA)
|
|
if not os.path.isdir(d):
|
|
raise Bad(f"{CORPORA}: no such directory")
|
|
out, errs = [], []
|
|
for f in sorted(os.listdir(d)):
|
|
if not f.endswith(".txt"):
|
|
continue
|
|
rel = f"{CORPORA}/{f}"
|
|
h, body, e = read_header(os.path.join(d, f), CORPUS_KEYS, rel)
|
|
errs += e
|
|
for k in CORPUS_REQUIRED:
|
|
if k not in h:
|
|
errs.append(f"{rel}: no `{k}:` line")
|
|
g = h.get("gen")
|
|
if g is not None and g != "none" and not all(re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", w) and w not in ("none", "verify") for w in g.split() or [""]):
|
|
errs.append(f"{rel}: `gen: {g}` is the tool's modes that write the corpus (lowercase words), or `none`")
|
|
if body.strip():
|
|
errs.append(f"{rel}: holds lines after its header; a corpus file is its header alone")
|
|
try:
|
|
order = int(h.get("order", "0"))
|
|
except ValueError:
|
|
errs.append(f"{rel}: order `{h.get('order')}` is not an integer")
|
|
order = 0
|
|
out.append({"slug": f[:-4], "file": rel, "corpus": h.get("corpus", ""), "order": order,
|
|
"bin": h.get("bin", ""), "dir": h.get("dir", "")})
|
|
out.sort(key=lambda c: (c["order"], c["slug"]))
|
|
return out, errs
|
|
|
|
|
|
def cargo_targets(root):
|
|
"""{(kind, name): required-features or []} of the crate's [[bin]] and [[test]] entries,
|
|
and the feature names of its [features] table."""
|
|
with open(os.path.join(root, CRATE, "Cargo.toml"), encoding="utf-8") as fh:
|
|
text = fh.read()
|
|
targets = {}
|
|
for block in re.split(r"^\[\[", text, flags=re.M)[1:]:
|
|
kind = block.split("]]", 1)[0]
|
|
body = re.split(r"^\[", block[len(kind) + 2:], flags=re.M)[0]
|
|
name = re.search(r'^name\s*=\s*"([^"]+)"', body, re.M)
|
|
if not name:
|
|
continue
|
|
rf = re.search(r"^required-features\s*=\s*\[(.*?)\]", body, re.M | re.S)
|
|
targets[(kind, name.group(1))] = sorted(re.findall(r'"([^"]+)"', rf.group(1))) if rf else []
|
|
sec = re.search(r"^\[features\]\n(.*?)(?=^\[)", text, re.M | re.S)
|
|
feats = set(re.findall(r"^([A-Za-z0-9_-]+)\s*=", sec.group(1), re.M)) if sec else set()
|
|
return targets, feats
|
|
|
|
|
|
def markers(root):
|
|
"""{test name: [tags]} of the crate's test files that carry a `wai-conformance:` line."""
|
|
tdir = os.path.join(root, CRATE, "tests")
|
|
out = {}
|
|
for f in sorted(os.listdir(tdir)):
|
|
if not f.endswith(".rs"):
|
|
continue
|
|
with open(os.path.join(tdir, f), encoding="utf-8") as fh:
|
|
found = MARKER.findall(fh.read())
|
|
if found:
|
|
out[f[:-3]] = uniq(t for line in found for t in line.split())
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------------------- derivations
|
|
|
|
|
|
def derive(mods):
|
|
union = [m for m in mods if m["build"] == "union"]
|
|
pq = [m for m in mods if m["build"] == "pq"]
|
|
feats = lambda ms: uniq(f for m in ms for lst in m["features"] for f in lst.split(","))
|
|
tests_with = lambda var: uniq(
|
|
t for m in mods for line in joined(m["body"]) if f'"${var}"' in line for t in re.findall(r"--test[ =]([A-Za-z0-9_-]+)", line))
|
|
bins_of = lambda key, build: uniq(
|
|
b for m in mods for b in m[key] + re.findall(rf"wai-bins/{build}/([A-Za-z0-9_-]+)", m["body"]))
|
|
return {
|
|
"WAI_FEATURES": feats(union),
|
|
"WAI_PQ_FEATURES": feats(pq),
|
|
"WAI_RELEASE_FEATURES": uniq(f for m in mods for f in m["release"]),
|
|
"union_bins": bins_of("bins", "union"),
|
|
"pq_bins": bins_of("pq_bins", "pq"),
|
|
"union_tests": tests_with("WAI_FEATURES"),
|
|
"release_tests": tests_with("WAI_RELEASE_FEATURES"),
|
|
"alone": uniq(lst for m in mods for lst in (m["features"] if m["alone"] is None else m["alone"])),
|
|
"paths": uniq(p for m in mods for p in m["paths"]),
|
|
}
|
|
|
|
|
|
def tagged(root, tags):
|
|
return sorted(t for t, ts in markers(root).items() if set(ts) & set(tags))
|
|
|
|
|
|
def tagged_features(root, tags, extra):
|
|
targets, _ = cargo_targets(root)
|
|
out = set(extra)
|
|
for t in tagged(root, tags):
|
|
out |= set(targets.get(("test", t)) or [])
|
|
return sorted(out)
|
|
|
|
|
|
def corpora_features(root, cs, extra):
|
|
targets, _ = cargo_targets(root)
|
|
out = set(extra)
|
|
for c in cs:
|
|
out |= set(targets.get(("bin", c["bin"])) or [])
|
|
return sorted(out)
|
|
|
|
|
|
def prebuild(d):
|
|
"""The build step's script: the binaries the steps run, copied per build, then the test
|
|
builds every step finds fresh. The order matters (see the step's comment)."""
|
|
s = ["set -euo pipefail", 'mkdir -p "$RUNNER_TEMP/wai-bins/union" "$RUNNER_TEMP/wai-bins/pq"']
|
|
def build(var, bins, dest):
|
|
if not bins:
|
|
return
|
|
s.append(f'cargo build --no-default-features --features "${var}" \\')
|
|
s.append(" " + " ".join(f"--bin {b}" for b in bins))
|
|
s.append(f"for b in {' '.join(bins)}; do")
|
|
s.append(f' cp "${{CARGO_TARGET_DIR:-target}}/debug/$b" "$RUNNER_TEMP/wai-bins/{dest}/"')
|
|
s.append("done")
|
|
build("WAI_FEATURES", d["union_bins"], "union")
|
|
if d["pq_bins"]:
|
|
s.append("# Copied before this build replaces target/debug's copies of the ones it shares.")
|
|
build("WAI_PQ_FEATURES", d["pq_bins"], "pq")
|
|
s.append('cargo test --no-run --no-default-features --features "$WAI_FEATURES" --lib \\')
|
|
s.append(" " + " ".join(f"--test {t}" for t in d["union_tests"]))
|
|
if d["release_tests"]:
|
|
s.append('cargo test --release --no-run --no-default-features --features "$WAI_RELEASE_FEATURES" \\')
|
|
s.append(" " + " ".join(f"--test {t}" for t in d["release_tests"]))
|
|
return "\n".join(s) + "\n"
|
|
|
|
|
|
def workflow_extras(text):
|
|
"""The features the workflow's steps add to their derived lists: {command: [features]},
|
|
the command being what precedes `--extra-features` on the step's line."""
|
|
out = {}
|
|
for m in re.finditer(r"wai-conformance-lists\.py ((?:[a-z-]+ )+)--extra-features ([A-Za-z0-9_,-]+)", text):
|
|
out.setdefault(m.group(1).strip(), m.group(2).split(","))
|
|
return out
|
|
|
|
|
|
def wide_globs(text):
|
|
"""The globs the workflow's gate replaces with the verifier files' paths (--wide)."""
|
|
return re.findall(r"--wide '([^']+)'", text)
|
|
|
|
|
|
def gate_paths(root):
|
|
"""What the gate runs on, as the gate reads it (scripts/pr-touches-paths.py with the
|
|
workflow's --paths-from and --wide): the workflow's paths but the wide ones, and the
|
|
verifier files' paths."""
|
|
gate = gate_module(root)
|
|
from pathlib import Path
|
|
wf = os.path.join(root, WORKFLOW)
|
|
with open(wf, encoding="utf-8") as fh:
|
|
text = fh.read()
|
|
dirs = [Path(os.path.join(root, d)) for d in re.findall(r"--paths-from (\S+)", text)]
|
|
globs = gate.gate_globs(Path(wf), dirs, wide_globs(text))
|
|
if globs is None:
|
|
raise Bad(f"the gate cannot read {WORKFLOW}'s paths with its --paths-from and --wide: it would run on every change")
|
|
return uniq(globs)
|
|
|
|
|
|
def from_tree(root):
|
|
"""Every list, as this tree's files give them."""
|
|
mods, errs = verifiers(root)
|
|
cs, e2 = corpora(root)
|
|
if errs + e2:
|
|
raise Bad("\n".join(errs + e2))
|
|
with open(os.path.join(root, WORKFLOW), encoding="utf-8") as fh:
|
|
text = fh.read()
|
|
x = workflow_extras(text)
|
|
d = derive(mods)
|
|
dbg = tagged(root, ["int-kernels"])
|
|
rel = tagged(root, ["int-kernels", "int-kernels-release"])
|
|
return {
|
|
"paths": gate_paths(root),
|
|
"WAI_FEATURES": d["WAI_FEATURES"], "WAI_PQ_FEATURES": d["WAI_PQ_FEATURES"],
|
|
"WAI_RELEASE_FEATURES": d["WAI_RELEASE_FEATURES"],
|
|
"union_bins": d["union_bins"], "pq_bins": d["pq_bins"],
|
|
"union_tests": d["union_tests"], "release_tests": d["release_tests"],
|
|
"alone": d["alone"],
|
|
"steps": [{"step": m["step"], "wd": m["wd"], "body": m["body"]} for m in mods if not m["declaration"]],
|
|
"declarations": [m["step"] for m in mods if m["declaration"]],
|
|
"independent": [f"{e['name']} {e['area']} {e['command']}" for m in mods for e in m["independent"]],
|
|
"int_kernels_debug_features": tagged_features(root, ["int-kernels"], x.get("test-tagged int-kernels", [])),
|
|
"int_kernels_debug_tests": dbg,
|
|
"int_kernels_release_features": tagged_features(root, ["int-kernels", "int-kernels-release"], x.get("test-tagged int-kernels int-kernels-release", [])),
|
|
"int_kernels_release_tests": rel,
|
|
"int_kernels_amd_features": tagged_features(root, ["int-kernels"], x.get("features int-kernels", [])),
|
|
"int_kernels_amd_tests": tagged(root, ["int-kernels-amd"]),
|
|
"corpora_features": corpora_features(root, cs, x.get("corpora --verify", [])),
|
|
"corpora": [f"{c['corpus']} {c['bin']} {c['dir']}" for c in cs],
|
|
}
|
|
|
|
|
|
# ------------------------------------------------- the lists a workflow kept by hand
|
|
|
|
|
|
def job_steps(text, job):
|
|
"""[{name, wd, body, if, shell, continue-on-error}] of a job's steps, read from the workflow
|
|
text."""
|
|
lines = text.split("\n")
|
|
start = next((i for i, l in enumerate(lines) if l == f" {job}:"), None)
|
|
if start is None:
|
|
raise Bad(f"{WORKFLOW} has no job `{job}`")
|
|
end = next((i for i in range(start + 1, len(lines)) if re.match(r"^ [a-z0-9_-]+:\s*$", lines[i])), len(lines))
|
|
out, i = [], start
|
|
while i < end:
|
|
m = re.match(r"^ - name: (.*)$", lines[i])
|
|
i += 1
|
|
if not m:
|
|
continue
|
|
s = {"name": m.group(1).strip(), "wd": None, "body": None, "if": None, "shell": None, "continue-on-error": None}
|
|
while i < end and lines[i].startswith(" "):
|
|
k = re.match(r"^ ([a-z-]+): ?(.*)$", lines[i])
|
|
if k and k.group(1) == "run" and k.group(2) == "|":
|
|
b = []
|
|
i += 1
|
|
while i < end and (lines[i].startswith(" ") or not lines[i].strip()):
|
|
b.append(lines[i][10:])
|
|
i += 1
|
|
s["body"] = "\n".join(b).rstrip("\n")
|
|
continue
|
|
if k and k.group(1) == "run":
|
|
s["body"] = k.group(2)
|
|
elif k and k.group(1) == "working-directory":
|
|
s["wd"] = k.group(2)
|
|
elif k and k.group(1) in ("if", "shell", "continue-on-error"):
|
|
s[k.group(1)] = k.group(2).strip()
|
|
i += 1
|
|
out.append(s)
|
|
return out
|
|
|
|
|
|
def steps_of_job(text, job):
|
|
"""[(name, working-directory, run body)] of a job's steps, read from the workflow text."""
|
|
return [(s["name"], s["wd"], s["body"]) for s in job_steps(text, job)]
|
|
|
|
|
|
def from_hand_lists(root):
|
|
"""Every list, as a workflow that kept them by hand states them (before this change)."""
|
|
gate = gate_module(root)
|
|
from pathlib import Path
|
|
wf = os.path.join(root, WORKFLOW)
|
|
with open(wf, encoding="utf-8") as fh:
|
|
text = fh.read()
|
|
env = lambda k: re.search(rf"^ {k}: (.*)$", text, re.M).group(1).split(",")
|
|
iv = steps_of_job(text, "independent-verifiers")
|
|
b = next(n for n, s in enumerate(iv) if s[0].startswith("Build wai-rs once"))
|
|
build = joined(iv[b][2])
|
|
pick = lambda pat: next(l for l in build if re.search(pat, l))
|
|
union_build = pick(r'cargo build .*"\$WAI_FEATURES"')
|
|
pq_build = pick(r'cargo build .*"\$WAI_PQ_FEATURES"')
|
|
union_test = pick(r'cargo test --no-run .*"\$WAI_FEATURES"')
|
|
rel_test = pick(r'cargo test --release --no-run')
|
|
fa = steps_of_job(text, "features-alone")
|
|
loop = re.search(r"for f in (.*?); do", " ".join(joined(fa[[s[0] for s in fa].index("wai-rs, each feature list alone")][2])))
|
|
cc = steps_of_job(text, "cargo-classes")
|
|
ik = lambda name: next(s[2] for s in cc if s[0] == name)
|
|
dbg = ik("Fast integer kernels ≡ reference kernels (overflow checks on)")
|
|
rel = ik("Fast integer kernels ≡ reference kernels (release, overflow checks off)")
|
|
corp = ik("Every cargo-class corpus reproduces its frozen hashes (unix)")
|
|
amd = steps_of_job(text, "int-kernels-amd")[-1][2]
|
|
tests = lambda l: re.findall(r"--test ([A-Za-z0-9_-]+)", l)
|
|
feats = lambda l: re.search(r"--features (\S+)", l).group(1).split(",")
|
|
return {
|
|
"paths": uniq(gate.paths_of(Path(wf))),
|
|
"WAI_FEATURES": env("WAI_FEATURES"), "WAI_PQ_FEATURES": env("WAI_PQ_FEATURES"),
|
|
"WAI_RELEASE_FEATURES": env("WAI_RELEASE_FEATURES"),
|
|
"union_bins": re.findall(r"--bin ([A-Za-z0-9_-]+)", union_build),
|
|
"pq_bins": re.findall(r"--bin ([A-Za-z0-9_-]+)", pq_build),
|
|
"union_tests": tests(union_test), "release_tests": tests(rel_test),
|
|
"alone": loop.group(1).split(),
|
|
"steps": [{"step": n, "wd": wd or ".", "body": body} for n, wd, body in iv[b + 1:]],
|
|
"int_kernels_debug_features": sorted(feats(dbg)), "int_kernels_debug_tests": sorted(tests(dbg)),
|
|
"int_kernels_release_features": sorted(feats(rel)), "int_kernels_release_tests": sorted(tests(rel)),
|
|
"int_kernels_amd_features": sorted(re.search(r"^F=(\S+)$", amd, re.M).group(1).split(",")),
|
|
"int_kernels_amd_tests": sorted(tests(re.search(r'^T="(.*)"$', amd, re.M).group(1))),
|
|
"corpora_features": sorted(re.search(r"^F=(\S+)$", corp, re.M).group(1).split(",")),
|
|
"corpora": [" ".join(m.groups()) for m in re.finditer(r"^v (\S+)\s+(\S+)\s+(\S+)$", corp, re.M)],
|
|
}
|
|
|
|
|
|
def at_ref(ref, fn):
|
|
"""fn(root) over the tree at a git ref, extracted to a scratch directory."""
|
|
tmp = tempfile.mkdtemp(prefix="wai-lists-")
|
|
try:
|
|
arch = subprocess.run(["git", "archive", ref, ".github", "scripts", f"{CRATE}/Cargo.toml", f"{CRATE}/tests"],
|
|
capture_output=True, check=True).stdout
|
|
subprocess.run(["tar", "-x", "-C", tmp], input=arch, check=True)
|
|
return fn(tmp)
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def lists_of(root):
|
|
return from_tree(root) if os.path.isdir(os.path.join(root, VERIFIERS)) else from_hand_lists(root)
|
|
|
|
|
|
# ------------------------------------------------- the command lines of the other steps
|
|
|
|
# The steps outside the independent verifiers whose lists are derived: (label, job, the
|
|
# start of the step's name). Their names lost the leg's suffix when the corpus step's two
|
|
# copies, one a shell family, became one step, so they are found by the start of the name.
|
|
COMMAND_STEPS = (
|
|
("cargo-classes: fast integer kernels, overflow checks on", "cargo-classes",
|
|
"Fast integer kernels ≡ reference kernels (overflow checks on)"),
|
|
("cargo-classes: fast integer kernels, release", "cargo-classes",
|
|
"Fast integer kernels ≡ reference kernels (release, overflow checks off)"),
|
|
("cargo-classes: every cargo-class corpus", "cargo-classes",
|
|
"Every cargo-class corpus reproduces its frozen hashes"),
|
|
("int-kernels-amd", "int-kernels-amd", "Fast integer kernels ≡ reference kernels, every kernel set"),
|
|
("features-alone", "features-alone", "wai-rs, each feature list alone"),
|
|
)
|
|
# cargo's options that take a value, as `--opt value` or `--opt=value`.
|
|
VALUE_OPTS = {"--features", "-F", "--test", "--bin", "--example", "--bench", "--target", "-p", "--package",
|
|
"--manifest-path", "--profile", "-j", "--jobs", "--target-dir"}
|
|
SCRIPT_CALL = re.compile(r"^python3\s+\S*wai-conformance-lists\.py\s+(.*)$")
|
|
|
|
|
|
def tagged_args(rest):
|
|
"""(tags, extra features, cargo args) of `tests|features|test-tagged TAG... [--extra-features
|
|
A,B] [-- ARGS...]`, refusing what would run no test."""
|
|
rest = list(rest)
|
|
cargo_args = []
|
|
if "--" in rest:
|
|
cargo_args = rest[rest.index("--") + 1:]
|
|
rest = rest[:rest.index("--")]
|
|
extra = [x for x in (opt(rest, "--extra-features") or "").split(",") if x]
|
|
unknown = [t for t in rest if t not in TAGS]
|
|
if unknown or not rest:
|
|
raise Bad(f"tags: {' '.join(unknown) or '(none given)'}; a tag is one of {', '.join(TAGS)}")
|
|
return rest, extra, cargo_args
|
|
|
|
|
|
def test_tagged_line(root, tags, extra, cargo_args):
|
|
names = tagged(root, tags)
|
|
if not names:
|
|
raise Bad(f"no test file carries {' or '.join(tags)}: the step would run no test")
|
|
feats = tagged_features(root, tags, extra)
|
|
return ["cargo", "test"] + cargo_args + ["--features", ",".join(feats)] + [x for t in names for x in ("--test", t)]
|
|
|
|
|
|
def corpus_lines(root, cs, extra):
|
|
f = ",".join(corpora_features(root, cs, extra))
|
|
return f, [["cargo", "run", "-q", "--no-default-features", "--features", f, "--bin", c["bin"], "--", "verify", c["dir"]]
|
|
for c in cs]
|
|
|
|
|
|
def script_value(root, argv):
|
|
"""What `$(python3 .../wai-conformance-lists.py ARGV)` prints, for the commands a step's
|
|
shell takes from it."""
|
|
cmd, rest = argv[0], argv[1:]
|
|
if cmd in ("tests", "features"):
|
|
tags, extra, _ = tagged_args(rest)
|
|
if not tagged(root, tags):
|
|
raise Bad(f"no test file carries {' or '.join(tags)}: the step would run no test")
|
|
if cmd == "tests":
|
|
return " ".join(f"--test {t}" for t in tagged(root, tags))
|
|
return ",".join(tagged_features(root, tags, extra))
|
|
if cmd == "alone":
|
|
mods, errs = verifiers(root)
|
|
if errs:
|
|
raise Bad("\n".join(errs))
|
|
return "\n".join(derive(mods)["alone"])
|
|
raise Bad(f"`$(wai-conformance-lists.py {cmd} ...)`: the dry-run reads no value of `{cmd}`")
|
|
|
|
|
|
def script_commands(root, argv):
|
|
"""The cargo command lines `python3 .../wai-conformance-lists.py ARGV` runs."""
|
|
cmd, rest = argv[0], argv[1:]
|
|
if cmd == "test-tagged":
|
|
return [test_tagged_line(root, *tagged_args(rest))]
|
|
if cmd == "corpora" and "--verify" in rest:
|
|
rest = list(rest)
|
|
extra = [x for x in (opt(rest, "--extra-features") or "").split(",") if x]
|
|
cs, errs = corpora(root)
|
|
if errs:
|
|
raise Bad("\n".join(errs))
|
|
return corpus_lines(root, cs, extra)[1]
|
|
raise Bad(f"`wai-conformance-lists.py {cmd}` in a step the dry-run reads: it reads the commands of test-tagged and corpora --verify only")
|
|
|
|
|
|
def subst(s, env):
|
|
"""s with `$NAME` and `${NAME}` replaced from env; Bad for a name with no known value."""
|
|
def rep(m):
|
|
name = m.group(1) or m.group(2)
|
|
if env.get(name) is None:
|
|
raise Bad(f"`${name}` has no value the dry-run knows")
|
|
return env[name]
|
|
return re.sub(r"\$\{(\w+)\}|\$(\w+)", rep, s)
|
|
|
|
|
|
def shell_value(rhs, env, root):
|
|
"""The value an assignment's right-hand side gives, or None when it reads the runner's
|
|
environment."""
|
|
m = re.match(r"^\$\(\s*(python3\s+\S*wai-conformance-lists\.py\s+.*?)\s*\)$", rhs)
|
|
if m:
|
|
return script_value(root, shlex.split(SCRIPT_CALL.match(m.group(1)).group(1)))
|
|
if rhs.startswith("$(") or "`" in rhs:
|
|
return None
|
|
try:
|
|
if rhs.startswith("'") and rhs.endswith("'") and len(rhs) > 1:
|
|
return rhs[1:-1]
|
|
if rhs.startswith('"') and rhs.endswith('"') and len(rhs) > 1:
|
|
return subst(rhs[1:-1], env)
|
|
return subst(rhs, env)
|
|
except Bad:
|
|
return None
|
|
|
|
|
|
def run_lines(lines, env, funcs, root, out):
|
|
"""Append to out the cargo command lines (as argv) that a step's shell lines run: bash or
|
|
PowerShell, as far as the steps COMMAND_STEPS names use them (assignments, `for` loops, a
|
|
function a line calls, the derivations of this script, and cargo itself). A line that names
|
|
cargo but none of these is refused rather than skipped."""
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i].strip()
|
|
i += 1
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
m = re.match(r"^for (\w+) in (.*); do$", line)
|
|
if m:
|
|
depth, j = 1, i
|
|
while j < len(lines):
|
|
t = lines[j].strip()
|
|
if re.match(r"^for \w+ in .*; do$", t):
|
|
depth += 1
|
|
elif t == "done":
|
|
depth -= 1
|
|
if not depth:
|
|
break
|
|
j += 1
|
|
else:
|
|
raise Bad(f"`{line}` has no `done`")
|
|
for v in shlex.split(subst(m.group(2), env)):
|
|
run_lines(lines[i:j], {**env, m.group(1): v}, funcs, root, out)
|
|
i = j + 1
|
|
continue
|
|
m = re.match(r"^(\w+)\(\)\s*\{\s*(.*?)\s*\}$", line)
|
|
if m:
|
|
funcs[m.group(1)] = (None, m.group(2).split(";"))
|
|
continue
|
|
m = re.match(r"^function (\w+)\((.*)\)\s*\{$", line)
|
|
if m:
|
|
j = next((j for j in range(i, len(lines)) if lines[j].strip() == "}"), None)
|
|
if j is None:
|
|
raise Bad(f"`{line}` has no closing `}}`")
|
|
funcs[m.group(1)] = ([p.strip().lstrip("$") for p in m.group(2).split(",") if p.strip()], lines[i:j])
|
|
i = j + 1
|
|
continue
|
|
m = re.match(r"^(?:export\s+)?\$?([A-Za-z_]\w*)\s*=\s*(.*)$", line)
|
|
if m:
|
|
env[m.group(1)] = shell_value(m.group(2), env, root)
|
|
continue
|
|
word = line.split()[0]
|
|
if word in funcs:
|
|
params, body = funcs[word]
|
|
args = shlex.split(subst(line[len(word):], env))
|
|
e = dict(env)
|
|
for n, a in enumerate(args, 1):
|
|
e[str(n)] = a
|
|
for p, a in zip(params or [], args):
|
|
e[p] = a
|
|
run_lines(body, e, funcs, root, out)
|
|
continue
|
|
m = SCRIPT_CALL.match(line)
|
|
if m:
|
|
out += script_commands(root, shlex.split(subst(m.group(1), env)))
|
|
continue
|
|
if re.search(r"(^|\s)cargo\s", line):
|
|
toks = shlex.split(subst(line, env))
|
|
if "cargo" not in toks:
|
|
raise Bad(f"`{line}`: the dry-run cannot read this command")
|
|
out.append(toks)
|
|
continue
|
|
if "wai-conformance-lists.py" in line:
|
|
raise Bad(f"`{line}`: the dry-run cannot read this call of the script")
|
|
|
|
|
|
def canon(toks):
|
|
"""A cargo command line in one form for every spelling of it: its flags sorted, its
|
|
features sorted and comma-joined, each target once and sorted, anything after `--` as it
|
|
was. Two commands that build and run the same thing print the same."""
|
|
at = toks.index("cargo")
|
|
pre, sub, rest = toks[:at], toks[at + 1], toks[at + 2:]
|
|
tail = []
|
|
if "--" in rest:
|
|
tail, rest = rest[rest.index("--"):], rest[:rest.index("--")]
|
|
flags, feats, vals = set(), set(), set()
|
|
i = 0
|
|
while i < len(rest):
|
|
t = rest[i]
|
|
if t.startswith("--") and "=" in t and t.split("=", 1)[0] in VALUE_OPTS:
|
|
k, v = t.split("=", 1)
|
|
i += 1
|
|
elif t in VALUE_OPTS:
|
|
if i + 1 >= len(rest):
|
|
raise Bad(f"`{' '.join(toks)}`: {t} with no value")
|
|
k, v = t, rest[i + 1]
|
|
i += 2
|
|
else:
|
|
flags.add(t)
|
|
i += 1
|
|
continue
|
|
if k in ("--features", "-F"):
|
|
feats |= {f for f in re.split(r"[ ,]+", v) if f}
|
|
else:
|
|
vals.add((k, v))
|
|
out = pre + ["cargo", sub] + sorted(flags) + (["--features", ",".join(sorted(feats))] if feats else [])
|
|
out += [x for k, v in sorted(vals) for x in (k, v)] + tail
|
|
return " ".join(shlex.quote(t) for t in out)
|
|
|
|
|
|
def admits(cond, leg):
|
|
"""Whether a step's `if:` lets it run on a leg of the family `leg` (unix or windows)."""
|
|
if cond is None:
|
|
return True
|
|
m = re.fullmatch(r"(?:\$\{\{\s*)?matrix\.os (==|!=) '([a-z0-9-]+)'(?:\s*\}\})?", cond)
|
|
if not m or m.group(2) != "windows":
|
|
raise Bad(f"a step's condition `{cond}` is none the dry-run reads (matrix.os == or != 'windows')")
|
|
return (m.group(1) == "==") == (leg == "windows")
|
|
|
|
|
|
def step_commands(root):
|
|
"""[(label, legs, working directories, [command lines])] of the steps COMMAND_STEPS names,
|
|
each command line in canon() form; `legs` is "every leg" when the unix legs and the
|
|
Windows leg run the same, and one entry a family otherwise."""
|
|
with open(os.path.join(root, WORKFLOW), encoding="utf-8") as fh:
|
|
text = fh.read()
|
|
out = []
|
|
for label, job, prefix in COMMAND_STEPS:
|
|
steps = [s for s in job_steps(text, job) if s["name"].startswith(prefix)]
|
|
if not steps:
|
|
raise Bad(f"{WORKFLOW}: job `{job}` has no step `{prefix}...`")
|
|
per = {}
|
|
for leg in ("unix", "windows"):
|
|
cmds, wds = [], []
|
|
for s in steps:
|
|
if not admits(s["if"], leg):
|
|
continue
|
|
argv = []
|
|
try:
|
|
run_lines(joined(s["body"] or ""), {}, {}, root, argv)
|
|
except Bad as e:
|
|
raise Bad(f"{WORKFLOW}: {job} / {s['name']}: {e}")
|
|
if not argv:
|
|
raise Bad(f"{WORKFLOW}: {job} / {s['name']}: the dry-run finds no cargo command in it")
|
|
cmds += [canon(a) for a in argv]
|
|
wds.append(s["wd"] or ".")
|
|
if label == "features-alone":
|
|
cmds = sorted(cmds)
|
|
per[leg] = (uniq(wds), cmds)
|
|
if per["unix"] == per["windows"]:
|
|
out.append((label, "every leg", *per["unix"]))
|
|
else:
|
|
out += [(label, "unix legs", *per["unix"]), (label, "the Windows leg", *per["windows"])]
|
|
return out
|
|
|
|
|
|
# ----------------------------------------------------------------------------- output
|
|
|
|
SETS = ("paths", "WAI_FEATURES", "WAI_PQ_FEATURES", "WAI_RELEASE_FEATURES", "union_bins", "pq_bins",
|
|
"union_tests", "release_tests", "alone", "int_kernels_debug_features", "int_kernels_debug_tests",
|
|
"int_kernels_release_features", "int_kernels_release_tests", "int_kernels_amd_features",
|
|
"int_kernels_amd_tests", "corpora_features")
|
|
|
|
|
|
def norm_list(k, xs):
|
|
if k == "alone":
|
|
return sorted(uniq(",".join(sorted(x.split(","))) for x in xs))
|
|
return sorted(uniq(xs))
|
|
|
|
|
|
def dry_run(L, C):
|
|
out = ["# wai-conformance.yml: what its derived lists make it run, in a stable form",
|
|
"# (lists sorted; steps and corpora in the order they run)"]
|
|
for k in SETS:
|
|
out.append(f"{k}: {' '.join(norm_list(k, L[k]))}")
|
|
out.append("corpora (in order):")
|
|
out += [f" {c}" for c in L["corpora"]]
|
|
if L.get("independent"):
|
|
out.append("independent verifiers, run by one step as `wai conform --independent` (in order):")
|
|
out += [f" {x}" for x in L["independent"]]
|
|
if L.get("declarations"):
|
|
out.append("declarations (step files whose verifiers run in the union alone):")
|
|
out += [f" {x}" for x in L["declarations"]]
|
|
out.append("independent-verifiers steps (in order):")
|
|
for s in L["steps"]:
|
|
out.append(f" - step: {s['step']} (working-directory: {s['wd']})")
|
|
out += [f" | {l}" for l in s["body"].split("\n")]
|
|
out.append("the other derived steps' commands (as cargo is given them: flags sorted, features sorted, each")
|
|
out.append("target once; in the order they run, but features-alone's sorted):")
|
|
for label, legs, wds, cmds in C:
|
|
out.append(f" - {label} ({legs}; working-directory: {', '.join(wds) or '-'})")
|
|
out += [f" $ {c}" for c in cmds]
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
UNION_JOB = "independent-verifiers"
|
|
# A line whose command is `wai`, by a variable or a path, with these words after it.
|
|
WAI_COMMAND = r'^(?:"\$\w+"|\S*wai) '
|
|
# The flags the union's line may take, each with whether it takes a value: where its records
|
|
# and report go, and how it signs and prints them. Any other narrows the run (--suite), stops
|
|
# it before it runs (--dry-run, --list), changes when a verifier fails (--timeout) or what runs
|
|
# it (--python, whose stand-in that exits 0 passes every verifier), or is no flag of it.
|
|
UNION_FLAGS = {"--root": True, "--out": True, "--format": True, "--key": True, "--json": False, "--text": False}
|
|
# A line that takes the step out of `set -e`'s hands: `set +e` in any of its spellings.
|
|
ERREXIT_OFF = re.compile(r"(?:^|[;&|(]\s*)(?:set\b[^;&|]*?\s(?:\+[A-Za-z]*e[A-Za-z]*|\+o\s+errexit)\b|shopt\b[^;&|]*\berrexit\b)")
|
|
|
|
|
|
def words(line):
|
|
"""The shell words of `line`, `(`, `)`, `&&`, `|`, `;` and `>` apart, a descriptor's number
|
|
before a redirection dropped."""
|
|
lex = shlex.shlex(re.sub(r"(?:^|(?<=\s))\d+(?=>)", "", line), posix=True, punctuation_chars=True)
|
|
lex.whitespace_split = True
|
|
try:
|
|
return list(lex)
|
|
except ValueError:
|
|
return line.split()
|
|
|
|
|
|
def assigned(var, lines):
|
|
"""What the last of `lines` that sets `var` gives it, or None when none does."""
|
|
val = None
|
|
for l in lines:
|
|
for m in re.finditer(rf"(?:^|[;&(]\s*)(?:(?:export|readonly|local|declare(?:\s+-\w+)*)\s+)?{re.escape(var)}=(\S*)", l):
|
|
w = words(m.group(1))
|
|
val = w[0] if w else ""
|
|
return val
|
|
|
|
|
|
def built_wai(lines):
|
|
"""The path of the `wai` the last `cargo build ... --bin wai --target-dir DIR` of `lines`
|
|
builds, or None."""
|
|
out = None
|
|
for l in lines:
|
|
w = words(l)
|
|
if "cargo" not in w:
|
|
continue
|
|
w = w[w.index("cargo"):]
|
|
if w[1:2] != ["build"] or "--bin" not in w or w[w.index("--bin") + 1:][:1] != ["wai"] or "--target-dir" not in w:
|
|
continue
|
|
d = w[w.index("--target-dir") + 1:][:1]
|
|
if d:
|
|
out = f"{d[0]}/{'release' if '--release' in w else 'debug'}/wai"
|
|
return out
|
|
|
|
|
|
def the_program(word, before):
|
|
"""The program a line's first word runs, a variable read from its last setting in `before`."""
|
|
m = re.fullmatch(r"\$\{?(\w+)\}?", word)
|
|
return assigned(m.group(1), before) if m else word
|
|
|
|
|
|
def left_open(lines):
|
|
"""What `lines` leave open for the line after them, which then is not a command the step
|
|
runs as written at its top level: "a quoted string", "a here-document", or "a block" (a `{`
|
|
or `(` not closed: a function, a group or a subshell). None when they leave nothing open.
|
|
`${...}` and `${{ ... }}` are no block; a comment is skipped."""
|
|
text = re.sub(r"\$\{\{.*?\}\}|\$\{[^}'\"\n]*\}", "", "\n".join(lines))
|
|
depth, quote, i = 0, None, 0
|
|
while i < len(text):
|
|
c = text[i]
|
|
if quote == "'":
|
|
quote = None if c == "'" else quote
|
|
elif quote == '"':
|
|
if c == "\\":
|
|
i += 1
|
|
elif c == '"':
|
|
quote = None
|
|
elif c == "\\":
|
|
i += 1
|
|
elif c in "'\"":
|
|
quote = c
|
|
elif c == "#" and (i == 0 or text[i - 1] in " \t\n;"):
|
|
j = text.find("\n", i)
|
|
i = len(text) if j < 0 else j
|
|
continue
|
|
elif text.startswith("<<", i) and not text.startswith("<<<", i):
|
|
return "a here-document"
|
|
elif c in "({":
|
|
depth += 1
|
|
elif c in ")}":
|
|
depth -= 1
|
|
i += 1
|
|
if quote:
|
|
return "a quoted string"
|
|
return "a block" if depth else None
|
|
|
|
|
|
def union_findings(text):
|
|
"""What of these the corpus union's step, in the workflow `text`, does not hold. The step
|
|
has no `if:` and no `continue-on-error:`, nor has its job the latter; it runs under `set -e`,
|
|
and no line turns that off (`set +e`, `set +o errexit`, `shopt ... errexit`); `wai conform
|
|
--independent` is a line's command, run in wai/ with `--root .`, with no flag but those of
|
|
UNION_FLAGS and nothing after it that could answer for its failure; the `wai` it runs, as
|
|
the `wai verify` after it that checks the records, is the one the step's `cargo build
|
|
--bin wai --target-dir DIR` built; neither line is inside a block, a quoted string or a
|
|
here-document that a line before it opens (a function never called, say); and no line
|
|
ignores a failure (`||`), runs under a condition or a trap, or ends the step. A step file's
|
|
verdict is `run`'s; the union's is this step's alone. These are the ways the step has been
|
|
planted to pass a failed run; the text is read, not run."""
|
|
lines = text.split("\n")
|
|
start = next((i for i, l in enumerate(lines) if l == f" {UNION_JOB}:"), None)
|
|
if start is None:
|
|
return [f"{WORKFLOW} has no job `{UNION_JOB}`"]
|
|
end = next((i for i in range(start + 1, len(lines)) if re.match(r"^ [a-z0-9_-]+:\s*$", lines[i])), len(lines))
|
|
out = []
|
|
if any(re.match(r"^ continue-on-error:", l) for l in lines[start:end]):
|
|
out.append(f"{WORKFLOW}: the job `{UNION_JOB}` has `continue-on-error:`, so the corpus union can fail and the job pass")
|
|
code = lambda s: [l.strip() for l in joined(s["body"] or "") if l.strip() and not l.strip().startswith("#")]
|
|
runs = [s for s in job_steps(text, UNION_JOB) if any(" conform --independent" in l for l in code(s))]
|
|
if len(runs) != 1:
|
|
return out + [f"{WORKFLOW}: `{UNION_JOB}` runs `wai conform --independent` in {len(runs)} steps, not one"]
|
|
step = runs[0]
|
|
where = f"{WORKFLOW}: the corpus union's step"
|
|
for k in ("if", "continue-on-error"):
|
|
if step[k] is not None:
|
|
out.append(f"{where} has `{k}:`: it can be skipped or fail without failing the job")
|
|
body = code(step)
|
|
at = next((i for i, l in enumerate(body) if re.match(WAI_COMMAND + r"conform --independent(?:\s|$)", l)), None)
|
|
if at is None:
|
|
return out + [f"{where} runs `wai conform --independent` in a line that is not its command"]
|
|
rest = re.sub(r"\d?>&\d", "", body[at])
|
|
if re.search(r"[|;&]", rest):
|
|
out.append(f"{where} goes on after the command on its line, so the line's status is not the union's: {body[at]}")
|
|
w = words(rest)
|
|
args = w[3:w.index(">")] if ">" in w else w[3:]
|
|
i = 0
|
|
while i < len(args):
|
|
a = args[i]
|
|
flag = a.split("=", 1)[0]
|
|
if flag not in UNION_FLAGS:
|
|
out.append(f"{where} gives the union `{a}`, which narrows it, stops it before it runs, changes what fails or runs it, or is no flag of it: it takes only {', '.join(UNION_FLAGS)}")
|
|
elif UNION_FLAGS[flag] and "=" not in a:
|
|
if flag == "--root" and args[i + 1:i + 2] != ["."]:
|
|
out.append(f"{where} gives the union `--root {' '.join(args[i + 1:i + 2])}`, not `.`: the corpora it reads are not wai/'s")
|
|
i += 1
|
|
elif flag == "--root" and a != "--root=.":
|
|
out.append(f"{where} gives the union `{a}`, not `--root .`: the corpora it reads are not wai/'s")
|
|
i += 1
|
|
if "--root" not in [a.split("=", 1)[0] for a in args]:
|
|
out.append(f"{where} gives the union no `--root .`")
|
|
if step["wd"] != "wai":
|
|
out.append(f"{where} runs in `{step['wd']}`, not wai/, where `--root .` is the corpora's")
|
|
built = built_wai(body[:at])
|
|
if built is None:
|
|
out.append(f"{where} builds no `wai` before the union (`cargo build ... --bin wai --target-dir DIR`)")
|
|
elif the_program(w[0], body[:at]) != built:
|
|
out.append(f"{where} runs `{w[0]}`, which is not the `wai` it built ({built})")
|
|
if not any(re.match(r"set -[a-z]*e", l) for l in body[:at]):
|
|
out.append(f"{where} has no `set -e` before the union, and its exit status decides nothing")
|
|
check = next((i for i in range(at + 1, len(body)) if re.match(WAI_COMMAND + r"verify\s", body[i])), None)
|
|
if check is None:
|
|
out.append(f"{where} does not check the records with `wai verify` after the union")
|
|
elif built is not None and the_program(words(body[check])[0], body[:check]) != built:
|
|
out.append(f"{where} checks the records with `{words(body[check])[0]}`, which is not the `wai` it built ({built})")
|
|
for what, i in (("`wai conform --independent`", at), ("`wai verify`", check)):
|
|
left = left_open(body[:i]) if i is not None else None
|
|
if left:
|
|
out.append(f"{where} has {what} inside {left} that a line before it opens, so the step need not run it as written")
|
|
for l in body:
|
|
if ERREXIT_OFF.search(l) or "||" in l or re.match(r"(?:if|while|until|for|case|trap)\s", l) or re.search(r"(?:^|[;&]\s*)(?:exit|return)\b", l):
|
|
out.append(f"{where} can pass a failed union: `{l}`; `set -e` decides it")
|
|
return out
|
|
|
|
|
|
BREAKING = ".github/workflows/wai-breaking.yml"
|
|
# What the tests of `wai conform`'s table read: wai-rs/build.rs compiles the first in, and the
|
|
# tests hold the second to running the union and no entry by hand.
|
|
TABLE_INPUTS = (".github/wai-conformance/verifiers/**", WORKFLOW)
|
|
|
|
|
|
def trigger_findings(text):
|
|
"""What of TABLE_INPUTS a trigger of wai-breaking.yml, in the workflow `text`, does not start
|
|
on. It is the one workflow that runs the table's tests, work reaches main by a push as well
|
|
as by a pull request, and each has a list of its own, so each is read alone: the union of the
|
|
two, which is what the gate reads, would hide a path one of them lost."""
|
|
lines = text.split("\n")
|
|
out = []
|
|
for trigger, change in (("push", "push"), ("pull_request", "pull request")):
|
|
start = next((i for i, l in enumerate(lines) if l == f" {trigger}:"), None)
|
|
if start is None:
|
|
out.append(f"{BREAKING} has no `{trigger}:` trigger")
|
|
continue
|
|
end = next((i for i in range(start + 1, len(lines)) if re.match(r"^ {0,2}[^ #]", lines[i])), len(lines))
|
|
paths = re.findall(r"^ - '([^']+)'\s*$", "\n".join(lines[start:end]), re.M)
|
|
for p in TABLE_INPUTS:
|
|
if p not in paths:
|
|
out.append(f"{BREAKING}: a {change} that changes {p} starts no run of the table's tests, which this workflow alone runs")
|
|
return out
|
|
|
|
|
|
def needs_findings(root, wai, suite):
|
|
"""The entries whose `needs=` is not what a run without packages shows: `wai conform
|
|
--independent` on a virtual environment's interpreter, which holds none. The packages'
|
|
absence is checked first, as with one installed there every entry would pass."""
|
|
mods, errs = verifiers(root)
|
|
if errs:
|
|
raise Bad("\n".join(errs))
|
|
entries = {e["name"]: (m, e) for m in mods for e in m["independent"]}
|
|
named = suite.split(",") if suite else list(entries)
|
|
unknown = [n for n in named if n not in entries]
|
|
if unknown:
|
|
raise Bad(f"--suite names no entry: {' '.join(unknown)}")
|
|
tmp = tempfile.mkdtemp(prefix="wai-no-packages-")
|
|
try:
|
|
subprocess.run([sys.executable, "-m", "venv", "--without-pip", tmp], check=True)
|
|
python = os.path.join(tmp, "bin", "python3")
|
|
for p in sorted({n for _, e in entries.values() for n in e["needs"]}):
|
|
if subprocess.call([python, "-c", f"import {p}"], stderr=subprocess.DEVNULL) == 0:
|
|
raise Bad(f"{python} imports {p}, and the run would show nothing")
|
|
cmd = [wai, "conform", "--independent", "--root", os.path.join(root, "wai"), "--python", python, "--json"]
|
|
ran = subprocess.run(cmd + (["--suite", ",".join(named)] if suite else []), stdout=subprocess.PIPE)
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
if ran.returncode not in (0, 1):
|
|
raise Bad(f"wai conform --independent exited {ran.returncode}: it did not run the verifiers")
|
|
rows = {r["name"]: r for r in json.loads(ran.stdout)["data"]["independent"]}
|
|
out = []
|
|
for n in named:
|
|
m, e = entries[n]
|
|
r = rows.get(n)
|
|
if r is None:
|
|
out.append(f"{m['file']}: independent `{n}` did not run")
|
|
elif e["needs"] and r["pass"]:
|
|
out.append(f"{m['file']}: independent `{n}` passes without {', '.join(e['needs'])}, which its needs= declares")
|
|
elif not e["needs"] and not r["pass"]:
|
|
out.append(f"{m['file']}: independent `{n}` fails without packages and declares no needs=: {r['got']}")
|
|
return out, len(named)
|
|
|
|
|
|
def check(root):
|
|
"""Every finding about the files this script reads."""
|
|
errs = []
|
|
try:
|
|
mods, e = verifiers(root)
|
|
cs, e2 = corpora(root)
|
|
except Bad as x:
|
|
return [str(x)]
|
|
errs += e + e2
|
|
targets, feats = cargo_targets(root)
|
|
tests = {n for k, n in targets if k == "test"} | {f[:-3] for f in os.listdir(os.path.join(root, CRATE, "tests")) if f.endswith(".rs")}
|
|
bins = {n for k, n in targets if k == "bin"}
|
|
# A checkout, rather than the files `dry-run --ref` extracts: directories can be checked.
|
|
full = os.path.isdir(os.path.join(root, CRATE, "src"))
|
|
gate = gate_module(root)
|
|
from pathlib import Path
|
|
wf_globs = gate.paths_of(Path(os.path.join(root, WORKFLOW)))
|
|
if sorted(gate.declared_paths(Path(os.path.join(root, VERIFIERS))) or []) != sorted(p for m in mods for p in m["paths"]):
|
|
errs.append(f"scripts/pr-touches-paths.py reads other paths from {VERIFIERS} than this script does")
|
|
try:
|
|
gate_paths(root)
|
|
except Bad as x:
|
|
errs.append(str(x))
|
|
for m in mods:
|
|
for lst in m["features"] + (m["alone"] or []) + m["release"]:
|
|
for f in lst.split(","):
|
|
if f not in feats:
|
|
errs.append(f"{m['file']}: feature `{f}` is not in {CRATE}/Cargo.toml [features]")
|
|
for line in joined(m["body"]):
|
|
for t in re.findall(r"--test[ =]([A-Za-z0-9_-]+)", line):
|
|
if "cd wai-rs" in line and t not in tests:
|
|
errs.append(f"{m['file']}: --test {t}: {CRATE} has no such test")
|
|
for b in m["bins"] + m["pq_bins"] + re.findall(r"wai-bins/(?:union|pq)/([A-Za-z0-9_-]+)", m["body"]):
|
|
if b not in bins:
|
|
errs.append(f"{m['file']}: binary `{b}` is not a [[bin]] of {CRATE}/Cargo.toml")
|
|
for p in m["paths"]:
|
|
# A path the forge's own filter does not cover never starts the workflow, whatever
|
|
# the gate would say: a file under the glob stands for every file it matches.
|
|
sample = p.replace("**", "s/s").replace("*", "s").replace("?", "s")
|
|
if not any(gate.matches(sample, g) for g in wf_globs):
|
|
errs.append(f"{m['file']}: path `{p}` is under none of {WORKFLOW}'s paths, so the forge would never start the workflow for it")
|
|
if full and not os.path.isdir(os.path.join(root, m["wd"])):
|
|
errs.append(f"{m['file']}: working-directory `{m['wd']}` does not exist")
|
|
seen = {}
|
|
every = [(m, e) for m in mods for e in m["independent"]]
|
|
for m, e in every:
|
|
if e["name"] in seen:
|
|
errs.append(f"{m['file']}: independent `{e['name']}` is also {seen[e['name']]}'s")
|
|
seen.setdefault(e["name"], m["file"])
|
|
if full:
|
|
wai = os.path.join(root, "wai")
|
|
if not os.path.isfile(os.path.join(wai, e["script"])):
|
|
errs.append(f"{m['file']}: independent `{e['name']}`: wai/{e['script']} is not there")
|
|
for a in e["args"]:
|
|
if "/" in a and not a.startswith("-") and not os.path.exists(os.path.join(wai, a)):
|
|
errs.append(f"{m['file']}: independent `{e['name']}`: wai/{a} is not there")
|
|
d = entry_dir(e, wai)
|
|
if not d or not os.path.isdir(os.path.join(wai, d)):
|
|
errs.append(f"{m['file']}: independent `{e['name']}`: no corpus directory (give dir=)")
|
|
if e["verdicts"] and not os.path.isfile(os.path.join(wai, e["verdicts"])):
|
|
errs.append(f"{m['file']}: independent `{e['name']}`: verdicts file wai/{e['verdicts']} is not there")
|
|
for m in mods:
|
|
for line in joined(m["body"]):
|
|
for o, e in every:
|
|
if runs_entry(line, e):
|
|
errs.append(f"{m['file']}: runs the independent verifier `{e['name']}` ({o['file']}) itself; "
|
|
f"the union step runs it, `wai conform --independent`")
|
|
for c in cs:
|
|
if c["bin"] not in bins:
|
|
errs.append(f"{c['file']}: binary `{c['bin']}` is not a [[bin]] of {CRATE}/Cargo.toml")
|
|
if full and not os.path.isdir(os.path.normpath(os.path.join(root, CRATE, c["dir"]))):
|
|
errs.append(f"{c['file']}: dir `{c['dir']}` is no directory (it is read from {CRATE})")
|
|
for t, ts in markers(root).items():
|
|
for tag in ts:
|
|
if tag not in TAGS:
|
|
errs.append(f"{CRATE}/tests/{t}.rs: `wai-conformance: {tag}` is no tag a step takes ({', '.join(TAGS)})")
|
|
with open(os.path.join(root, WORKFLOW), encoding="utf-8") as fh:
|
|
errs += union_findings(fh.read())
|
|
with open(os.path.join(root, BREAKING), encoding="utf-8") as fh:
|
|
errs += trigger_findings(fh.read())
|
|
return errs
|
|
|
|
|
|
def selftest(root, exact):
|
|
errs = check(root)
|
|
for e in errs:
|
|
print(f"check: {e}")
|
|
if errs:
|
|
return 1
|
|
with open(os.path.join(root, BASELINE), encoding="utf-8") as fh:
|
|
base = json.load(fh)
|
|
L = from_tree(root)
|
|
added = base.get("added", {})
|
|
bad = 0
|
|
for k in SETS + ("corpora",):
|
|
want = norm_list(k, base[k] + added.get(k, [])) if k in SETS else base[k]
|
|
got = norm_list(k, L[k]) if k in SETS else L[k]
|
|
lost = [x for x in want if x not in got]
|
|
more = [x for x in got if x not in want]
|
|
if lost:
|
|
print(f"FAIL {k}: lost {' '.join(lost)}")
|
|
bad += 1
|
|
elif exact and more:
|
|
print(f"FAIL {k}: more than the hand list: {' '.join(more)}")
|
|
bad += 1
|
|
else:
|
|
print(f"ok {k}: the hand list's {len(want)}{' and ' + str(len(more)) + ' more' if more else ''}")
|
|
names = [s["step"] for s in L["steps"]]
|
|
declared = L.get("declarations", [])
|
|
for s in base["steps"]:
|
|
if s["step"] not in names and s["step"] not in declared:
|
|
print(f"FAIL step `{s['step']}` no longer runs")
|
|
bad += 1
|
|
if exact:
|
|
# Each by name, not by place: a step or a corpus the tree adds is one addition, and
|
|
# moves no other along. The order is that of the entries both hold.
|
|
want = [s["step"] for s in base["steps"]]
|
|
for n in names:
|
|
if n not in want:
|
|
print(f"FAIL step `{n}`: more than the hand list")
|
|
bad += 1
|
|
if [n for n in names if n in want] != [n for n in want if n in names]:
|
|
print("FAIL the steps both lists hold run in another order than the hand list's")
|
|
bad += 1
|
|
derived = {s["step"]: s for s in L["steps"]}
|
|
for s in base["steps"]:
|
|
t = derived.get(s["step"])
|
|
if t is not None and (s["wd"], s["body"]) != (t["wd"], t["body"]):
|
|
print(f"FAIL step `{s['step']}`: its body or directory is not the hand list's")
|
|
bad += 1
|
|
if [c for c in L["corpora"] if c in base["corpora"]] != [c for c in base["corpora"] if c in L["corpora"]]:
|
|
print("FAIL the corpora both lists hold run in another order than the hand list's")
|
|
bad += 1
|
|
through = sum(1 for s in base["steps"] if s["step"] in declared)
|
|
print(f"ok {len(base['steps'])} steps of the hand list run" + (f" ({through} of them as declarations whose verifiers the union runs)" if through else "")
|
|
if not bad else f"wai-conformance-lists selftest: {bad} failure(s)")
|
|
return 1 if bad else 0
|
|
|
|
|
|
# ------------------------------------------------------------------------------ main
|
|
|
|
|
|
def opt(args, name):
|
|
if name in args:
|
|
i = args.index(name)
|
|
v = args[i + 1]
|
|
del args[i:i + 2]
|
|
return v
|
|
return None
|
|
|
|
|
|
def main(argv):
|
|
args = list(argv)
|
|
root = opt(args, "--root") or os.path.dirname(HERE)
|
|
if not args:
|
|
print(__doc__)
|
|
return 2
|
|
cmd, rest = args[0], args[1:]
|
|
try:
|
|
if cmd in ("env", "prebuild", "run", "alone", "paths", "independent"):
|
|
mods, errs = verifiers(root)
|
|
if errs:
|
|
raise Bad("\n".join(errs))
|
|
d = derive(mods)
|
|
if cmd == "env":
|
|
for k in ("WAI_FEATURES", "WAI_PQ_FEATURES", "WAI_RELEASE_FEATURES"):
|
|
print(f"{k}={','.join(d[k])}")
|
|
elif cmd == "prebuild":
|
|
sys.stdout.write(prebuild(d))
|
|
elif cmd == "alone":
|
|
print("\n".join(d["alone"]))
|
|
elif cmd == "paths":
|
|
print("\n".join(d["paths"]))
|
|
elif cmd == "independent":
|
|
for m in mods:
|
|
for e in m["independent"]:
|
|
print(f"{e['name']} {e['area']} {e['command']}")
|
|
else:
|
|
only = [rest[i + 1] for i, a in enumerate(rest) if a == "--only"]
|
|
env = dict(os.environ)
|
|
for k in ("WAI_FEATURES", "WAI_PQ_FEATURES", "WAI_RELEASE_FEATURES"):
|
|
env[k] = ",".join(d[k])
|
|
env.setdefault("RUNNER_TEMP", tempfile.gettempdir())
|
|
todo = [m for m in mods if not only or m["slug"] in only]
|
|
if only and len(todo) != len(set(only)):
|
|
raise Bad(f"--only names a step no file holds: {' '.join(sorted(set(only) - {m['slug'] for m in mods}))}")
|
|
for m in [m for m in todo if m["declaration"]]:
|
|
print(f"{m['step']} ({m['file']}): a declaration; its verifiers run in the union step, "
|
|
f"`wai conform --independent`: {', '.join(e['name'] for e in m['independent'])}", flush=True)
|
|
todo = [m for m in todo if not m["declaration"]]
|
|
for m in todo:
|
|
print(f"::group::{m['step']} ({m['file']})", flush=True)
|
|
rc = subprocess.call(["bash", "--noprofile", "--norc", "-eo", "pipefail", os.path.join(root, m["file"])],
|
|
cwd=os.path.join(root, m["wd"]), env=env)
|
|
print("::endgroup::", flush=True)
|
|
if rc:
|
|
print(f"::error::{m['step']} failed with exit status {rc} ({m['file']})", flush=True)
|
|
return rc
|
|
print(f"every step ran and passed: {len(todo)} of {sum(1 for m in mods if not m['declaration'])}")
|
|
elif cmd == "needs":
|
|
suite = opt(rest, "--suite")
|
|
if len(rest) != 1:
|
|
raise Bad("needs: name the `wai` to run: needs <wai> [--suite A,B]")
|
|
found, n = needs_findings(root, rest[0], suite)
|
|
for f in found:
|
|
print(f"FAIL {f}")
|
|
print(f"needs: {n - len(found)} of {n} entries declare what a run without packages shows")
|
|
return 1 if found else 0
|
|
elif cmd in ("tests", "features"):
|
|
print(script_value(root, [cmd] + rest))
|
|
elif cmd == "test-tagged":
|
|
line = test_tagged_line(root, *tagged_args(rest))
|
|
print("+ " + " ".join(line), flush=True)
|
|
return subprocess.call(line)
|
|
elif cmd == "corpora":
|
|
extra = [x for x in (opt(rest, "--extra-features") or "").split(",") if x]
|
|
cs, errs = corpora(root)
|
|
if errs:
|
|
raise Bad("\n".join(errs))
|
|
if "--verify" not in rest:
|
|
for c in cs:
|
|
print(f"{c['corpus']} {c['bin']} {c['dir']}")
|
|
return 0
|
|
f, lines = corpus_lines(root, cs, extra)
|
|
print(f"the corpora's features: {f}", flush=True)
|
|
for c, line in zip(cs, lines):
|
|
print(f"::group::{c['corpus']}", flush=True)
|
|
print("+ " + " ".join(line), flush=True)
|
|
rc = subprocess.call(line)
|
|
print("::endgroup::", flush=True)
|
|
if rc:
|
|
print(f"::error::the {c['corpus']} corpus failed with exit status {rc} ({c['file']})", flush=True)
|
|
return rc
|
|
elif cmd == "dry-run":
|
|
ref = opt(rest, "--ref")
|
|
both = lambda r: (lists_of(r), step_commands(r))
|
|
L, C = at_ref(ref, both) if ref else both(root)
|
|
sys.stdout.write(dry_run(L, C))
|
|
elif cmd == "baseline":
|
|
ref = opt(rest, "--ref")
|
|
if not ref:
|
|
raise Bad("baseline --ref REF: the commit whose workflow kept the lists by hand")
|
|
L = at_ref(ref, from_hand_lists)
|
|
commit = subprocess.run(["git", "rev-parse", ref], capture_output=True, text=True, check=True).stdout.strip()
|
|
print(json.dumps({"commit": commit, **L}, indent=1, ensure_ascii=False))
|
|
elif cmd == "check":
|
|
errs = check(root)
|
|
for e in errs:
|
|
print(e)
|
|
if errs:
|
|
print(f"wai-conformance-lists check: {len(errs)} finding(s)")
|
|
return 1
|
|
print("wai-conformance-lists check: every file is well formed and names what exists.")
|
|
elif cmd == "selftest":
|
|
return selftest(root, "--exact" in rest)
|
|
else:
|
|
print(__doc__)
|
|
return 2
|
|
except Bad as e:
|
|
print(f"wai-conformance-lists {cmd}: {e}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|