mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-30 07:37:36 +00:00
The node-synthetic group is an allow-list of test modules, and an allow-list goes stale the day the next socket test lands: the new module runs beside the others and the first sign is a dropped handshake on a loaded runner, far from the cause. testing/check-node-test-groups.py recomputes the membership. It flags every function under src/node/tests/ (and in src/testutil.rs) that uses a socket primitive or calls a flagged function, to a fixpoint, so modules that reach a socket only through a helper are caught. Module names come from mod.rs's declarations. It then evaluates both profiles' overrides in order, as nextest does, and fails when a test in a flagged module is not in node-synthetic or a lock_large_network_test() holder is not in node-large. It also checks the nextest pin in the workflows matches the config. It exits 2 rather than pass when it finds no test modules, no flagged module, no lock holder, or a filter it cannot evaluate, so a moved directory or a reformatted filter cannot pass vacuously. Uses that open no socket are exempted one function and primitive at a time, each with its reason, in testing/node-test-groups/exemptions.toml. The fixtures beside it cover each way a test reaches a socket and each check the guard makes; every case names the output it expects, so a case that fails for the wrong reason fails the runner. Both run from local CI's guards and from the ci-parity job.
825 lines
34 KiB
Python
Executable File
825 lines
34 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Fail when a node test that needs to run alone is outside its nextest group.
|
|
|
|
nextest runs each test in its own process, so an in-process lock cannot keep
|
|
two tests apart. `.config/nextest.toml` does it instead with two one-at-a-time
|
|
test groups, and this check keeps their membership honest:
|
|
|
|
node-synthetic every test module under src/node/tests/ that opens a real
|
|
socket, so a loaded host cannot drop a one-shot handshake
|
|
while another such test floods the loopback interface, and
|
|
so the control socket's shared default path (TCP 21210 on
|
|
Windows) is bound by one test at a time;
|
|
node-large every test that takes `lock_large_network_test()`, so the
|
|
100-node tests stay mutually exclusive across processes as
|
|
they are across threads.
|
|
|
|
Without it the groups are an allow-list that goes stale the day the next
|
|
socket test lands, and nothing says so until a loaded runner drops a packet.
|
|
|
|
A module needs node-synthetic when any function in it is flagged. A function
|
|
is flagged when its text (signature and body, comments and string contents
|
|
removed) uses a primitive, or calls a flagged function, computed to a fixpoint:
|
|
|
|
primitives a non-loopback transport (UdpTransport, TcpTransport,
|
|
EthernetTransport, Ble*Transport); an OS socket type (UdpSocket,
|
|
TcpListener, TcpStream, TcpSocket, socket2, libc::socket); a
|
|
per-peer connected UDP socket (`open_connected_fd(`, which opens
|
|
one, and ConnectedPeerSocket, which only wraps such a socket);
|
|
and `.start()` and `run_rx_loop(` with its variants
|
|
(`run_rx_loop_with_shutdown(` is where the bind happens), which
|
|
bind through the node's Config, the control socket included.
|
|
calls `f(`, `path::f(` and `Type::f(` resolved through the module's own
|
|
functions, its `use` items (aliases, groups and globs) and
|
|
`super`/`self`/`crate::node::tests`/`crate::testutil` paths;
|
|
`.f(` matches every scanned method named f whose impl type is
|
|
named somewhere in the calling file (so a Vec's `.drain(` is not
|
|
a test helper's); a bare `f` passed as an argument counts as a
|
|
call.
|
|
|
|
The scanned files are the modules src/node/tests/mod.rs declares (names come
|
|
from the `mod` items, `#[path]` honoured, never from file names) and
|
|
src/testutil.rs, the crate's shared test helpers. A test's name is
|
|
node::tests::<module>[::<inline mod>...]::<fn>. node-large holds the tests
|
|
that call `lock_large_network_test(` directly or through a flagged helper.
|
|
|
|
Each profile's test-group overrides are checked in order, as nextest applies
|
|
them (the first matching override sets the group). The filters must have one
|
|
of two shapes, anchored and package-scoped, and anything else is a filter this
|
|
check cannot evaluate, so it exits 2 rather than guess:
|
|
|
|
package(<pkg>) & test(/^node::tests::(m1|m2|...)::/) node-synthetic
|
|
package(<pkg>) & test(/^node::tests::(m::t1|m::t2|...)$/) node-large
|
|
|
|
A module or test a filter names that this tree lacks is fine: one file serves
|
|
maint, master and next, and a name matching nothing is harmless. A module
|
|
named but present and not flagged is reported as a note, since another trunk
|
|
may flag it.
|
|
|
|
A function whose primitive use opens no socket (a transport built and never
|
|
started, say) is exempted in testing/node-test-groups/exemptions.toml, one
|
|
entry per function and primitive, each with its reason. An exemption with no
|
|
reason exits 2; one naming a function this tree lacks is a note, for the same
|
|
reason as above.
|
|
|
|
The pinned nextest version is checked too: `nextest-version.required` in the
|
|
config must equal the version of every `tool: nextest@<ver>` install step in
|
|
.github/workflows, and an unpinned one is a finding.
|
|
|
|
Exit 0: every flagged module and every lock holder is in its group, in both
|
|
profiles. Exit 1: a finding. Exit 2: the check could not run meaningfully:
|
|
no test modules, no flagged module, no lock holder, a filter of another shape,
|
|
a missing group, an unreadable file, an exemption without a reason, or no
|
|
nextest install step, so a moved directory or a reformatted filter cannot pass
|
|
vacuously.
|
|
|
|
`--survey` prints the membership with each flagged function's first reason,
|
|
for recording; `--root DIR` checks another tree (the fixtures use it).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
import tomllib
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
GROUP_SOCKET = "node-synthetic"
|
|
GROUP_LARGE = "node-large"
|
|
PROFILES = ("default", "ci")
|
|
LOCK_FN = "lock_large_network_test"
|
|
TESTS_PREFIX = "node::tests::"
|
|
|
|
PRIMITIVES = [
|
|
("UdpTransport", re.compile(r"\bUdpTransport\b")),
|
|
("TcpTransport", re.compile(r"\bTcpTransport\b")),
|
|
("EthernetTransport", re.compile(r"\bEthernetTransport\b")),
|
|
("BleTransport", re.compile(r"\bBle\w*Transport\b")),
|
|
("UdpSocket", re.compile(r"\bUdpSocket\b")),
|
|
("TcpListener", re.compile(r"\bTcpListener\b")),
|
|
("TcpStream", re.compile(r"\bTcpStream\b")),
|
|
("TcpSocket", re.compile(r"\bTcpSocket\b")),
|
|
("socket2", re.compile(r"\bsocket2\b")),
|
|
("libc::socket", re.compile(r"\blibc\s*::\s*socket\s*\(")),
|
|
("open_connected_fd", re.compile(r"\bopen_connected_fd\s*\(")),
|
|
("ConnectedPeerSocket", re.compile(r"\bConnectedPeerSocket\b")),
|
|
("start()", re.compile(r"\.\s*start\s*\(\s*\)")),
|
|
("run_rx_loop", re.compile(r"\brun_rx_loop\w*\s*\(")),
|
|
]
|
|
|
|
IDENT = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
SYNTH_RE = re.compile(
|
|
r"^package\((?P<pkg>[A-Za-z0-9_-]+)\) & "
|
|
r"test\(/\^node::tests::\((?P<alts>" + IDENT + r"(?:\|" + IDENT + r")*)\)::/\)$")
|
|
LARGE_RE = re.compile(
|
|
r"^package\((?P<pkg>[A-Za-z0-9_-]+)\) & "
|
|
r"test\(/\^node::tests::\((?P<alts>" + IDENT + r"(?:::" + IDENT + r")+"
|
|
r"(?:\|" + IDENT + r"(?:::" + IDENT + r")+)*)\)\$/\)$")
|
|
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
|
|
|
|
class CannotCheck(Exception):
|
|
"""The input cannot be checked meaningfully; the check exits 2."""
|
|
|
|
|
|
# ── Lexing ──────────────────────────────────────────────────────────────────
|
|
|
|
def strip_rust(text: str) -> str:
|
|
"""Blank comments and string and char literal contents, keeping offsets.
|
|
|
|
Every removed character becomes a space, except newlines, so a position in
|
|
the result is the same position (and line) in the source.
|
|
"""
|
|
out = list(text)
|
|
n = len(text)
|
|
i = 0
|
|
|
|
def blank(start: int, end: int) -> None:
|
|
"""Blank text[start:end], keeping newlines."""
|
|
for k in range(start, min(end, n)):
|
|
if out[k] != "\n":
|
|
out[k] = " "
|
|
|
|
while i < n:
|
|
c = text[i]
|
|
if text.startswith("//", i):
|
|
end = text.find("\n", i)
|
|
end = n if end < 0 else end
|
|
blank(i, end)
|
|
i = end
|
|
elif text.startswith("/*", i):
|
|
depth, j = 1, i + 2
|
|
while j < n and depth:
|
|
if text.startswith("/*", j):
|
|
depth, j = depth + 1, j + 2
|
|
elif text.startswith("*/", j):
|
|
depth, j = depth - 1, j + 2
|
|
else:
|
|
j += 1
|
|
blank(i, j)
|
|
i = j
|
|
elif c in "rb" and (i == 0 or not (text[i - 1].isalnum() or text[i - 1] == "_")):
|
|
m = re.match(r'(?:br|r)(#*)"', text[i:])
|
|
if m:
|
|
close = '"' + m.group(1)
|
|
end = text.find(close, i + m.end())
|
|
end = n if end < 0 else end + len(close)
|
|
blank(i, end)
|
|
i = end
|
|
elif text.startswith('b"', i):
|
|
i = skip_string(text, i + 1, blank)
|
|
elif text.startswith("b'", i):
|
|
i = skip_char(text, i + 1, blank)
|
|
else:
|
|
i += 1
|
|
elif c == '"':
|
|
i = skip_string(text, i, blank)
|
|
elif c == "'":
|
|
i = skip_char(text, i, blank)
|
|
else:
|
|
i += 1
|
|
return "".join(out)
|
|
|
|
|
|
def skip_string(text: str, start: int, blank: Callable[[int, int], None]) -> int:
|
|
"""Blank the ordinary string literal opening at `start`; return its end."""
|
|
j = start + 1
|
|
while j < len(text) and text[j] != '"':
|
|
j += 2 if text[j] == "\\" else 1
|
|
blank(start, j + 1)
|
|
return j + 1
|
|
|
|
|
|
def skip_char(text: str, start: int, blank: Callable[[int, int], None]) -> int:
|
|
"""Blank a char literal at `start`, or step over a lifetime."""
|
|
if start + 1 < len(text) and text[start + 1] == "\\":
|
|
end = text.find("'", start + 2)
|
|
end = len(text) if end < 0 else end + 1
|
|
blank(start, end)
|
|
return end
|
|
if start + 2 < len(text) and text[start + 2] == "'":
|
|
blank(start, start + 3)
|
|
return start + 3
|
|
return start + 1
|
|
|
|
|
|
def match_brace(text: str, open_at: int) -> int:
|
|
"""Return the index just past the brace matching the `{` at `open_at`."""
|
|
depth = 0
|
|
for k in range(open_at, len(text)):
|
|
if text[k] == "{":
|
|
depth += 1
|
|
elif text[k] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return k + 1
|
|
return len(text)
|
|
|
|
|
|
def line_of(text: str, pos: int) -> int:
|
|
"""1-based line number of offset `pos`."""
|
|
return text.count("\n", 0, pos) + 1
|
|
|
|
|
|
# ── Model ───────────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class Func:
|
|
"""A function item in a scanned file."""
|
|
|
|
module: str # top-level test module name, "" for mod.rs, "testutil"
|
|
name: str
|
|
start: int
|
|
end: int
|
|
text: str # stripped text from `fn` to the end of the body
|
|
line: int
|
|
inline: list[str] # inline `mod` path within the file
|
|
impl_type: str | None
|
|
is_test: bool
|
|
flagged: str | None = None # first reason, once flagged
|
|
large: str | None = None # first reason it holds the large-network lock
|
|
|
|
@property
|
|
def key(self) -> str:
|
|
"""Module-qualified name used in reports and exemptions."""
|
|
owner = [self.impl_type] if self.impl_type else []
|
|
parts = [self.module or "<mod.rs>", *self.inline, *owner, self.name]
|
|
return "::".join(parts)
|
|
|
|
@property
|
|
def test_name(self) -> str:
|
|
"""The test's name as nextest reports it."""
|
|
parts = [p for p in (self.module, *self.inline, self.name) if p]
|
|
return TESTS_PREFIX + "::".join(parts)
|
|
|
|
|
|
@dataclass
|
|
class Use:
|
|
"""One leaf of a `use` tree: a path, and an alias or None for a glob."""
|
|
|
|
path: list[str]
|
|
alias: str | None # None means `path::*`
|
|
depth: int # inline-mod depth where the `use` sits
|
|
|
|
|
|
@dataclass
|
|
class Module:
|
|
"""A scanned file and what it defines and imports."""
|
|
|
|
name: str
|
|
path: Path
|
|
text: str = ""
|
|
funcs: list[Func] = field(default_factory=list)
|
|
uses: list[Use] = field(default_factory=list)
|
|
|
|
|
|
def spans(text: str, pattern: re.Pattern) -> list[tuple[int, int, re.Match]]:
|
|
"""Each match of a `... {` header with the span of its braced block."""
|
|
found = []
|
|
for m in pattern.finditer(text):
|
|
brace = m.end() - 1
|
|
found.append((brace, match_brace(text, brace), m))
|
|
return found
|
|
|
|
|
|
MOD_BLOCK_RE = re.compile(r"\bmod\s+(" + IDENT + r")\s*\{")
|
|
IMPL_RE = re.compile(r"(?m)^[ \t]*(?:unsafe\s+)?impl\b[^;{]*\{")
|
|
FN_RE = re.compile(r"\bfn\s+(" + IDENT + r")")
|
|
TEST_ATTR_RE = re.compile(r"#\[\s*(?:tokio\s*::\s*)?test\b")
|
|
USE_RE = re.compile(r"\buse\s+([^;]+);")
|
|
|
|
|
|
def impl_type(header: str) -> str | None:
|
|
"""The implementing type's last path segment from an `impl ... {` header."""
|
|
body = header.strip()
|
|
body = body[body.index("impl") + len("impl"):].rstrip("{").strip()
|
|
if body.startswith("<"):
|
|
depth = 0
|
|
for k, ch in enumerate(body):
|
|
depth += {"<": 1, ">": -1}.get(ch, 0)
|
|
if depth == 0:
|
|
body = body[k + 1:]
|
|
break
|
|
if re.search(r"\bfor\b", body):
|
|
body = re.split(r"\bfor\b", body, maxsplit=1)[1]
|
|
body = re.split(r"\bwhere\b", body)[0]
|
|
names = re.findall(IDENT, body.split("<")[0])
|
|
return names[-1] if names else None
|
|
|
|
|
|
def expand_use(tree: str, prefix: list[str]) -> list[tuple[list[str], str | None]]:
|
|
"""Expand a `use` tree into (path, alias) leaves; alias None means a glob."""
|
|
tree = tree.strip()
|
|
if not tree:
|
|
return []
|
|
m = re.match(r"^((?:" + IDENT + r"\s*::\s*)*)\{(.*)\}$", tree, re.S)
|
|
if m:
|
|
head = [s for s in re.split(r"\s*::\s*", m.group(1).strip()) if s]
|
|
leaves: list[tuple[list[str], str | None]] = []
|
|
for part in split_top(m.group(2)):
|
|
leaves.extend(expand_use(part, prefix + head))
|
|
return leaves
|
|
if tree.endswith("*"):
|
|
head = [s for s in re.split(r"\s*::\s*", tree[:-1].strip()) if s]
|
|
return [(prefix + head, None)]
|
|
alias_m = re.match(r"^(.*?)\s+as\s+(" + IDENT + r")$", tree, re.S)
|
|
path_text, alias = (alias_m.group(1), alias_m.group(2)) if alias_m else (tree, None)
|
|
path = prefix + [s for s in re.split(r"\s*::\s*", path_text.strip()) if s]
|
|
if not path:
|
|
return []
|
|
if path[-1] == "self":
|
|
path = path[:-1]
|
|
return [(path, alias or path[-1])] if path else []
|
|
|
|
|
|
def split_top(text: str) -> list[str]:
|
|
"""Split a `use` group's contents on top-level commas."""
|
|
parts, depth, cur = [], 0, []
|
|
for ch in text:
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if ch == "," and depth == 0:
|
|
parts.append("".join(cur))
|
|
cur = []
|
|
else:
|
|
cur.append(ch)
|
|
parts.append("".join(cur))
|
|
return [p for p in (s.strip() for s in parts) if p]
|
|
|
|
|
|
def scan(mod: Module) -> None:
|
|
"""Fill a module's functions and `use` leaves from its stripped text."""
|
|
text = mod.text
|
|
inline = [(s, e, m.group(1)) for s, e, m in spans(text, MOD_BLOCK_RE)]
|
|
impls = [(s, e, impl_type(m.group(0))) for s, e, m in spans(text, IMPL_RE)]
|
|
tests = [m.start() for m in TEST_ATTR_RE.finditer(text)]
|
|
|
|
def inline_path(pos: int) -> list[str]:
|
|
"""The inline `mod` blocks enclosing `pos`, outermost first."""
|
|
return [name for s, e, name in sorted(inline) if s < pos < e]
|
|
|
|
fn_starts = [m for m in FN_RE.finditer(text)]
|
|
test_fns = set()
|
|
for attr in tests:
|
|
nxt = next((m.start() for m in fn_starts if m.start() > attr), None)
|
|
if nxt is not None:
|
|
test_fns.add(nxt)
|
|
|
|
for m in fn_starts:
|
|
depth, k, body = 0, m.end(), None
|
|
while k < len(text):
|
|
ch = text[k]
|
|
if ch in "([":
|
|
depth += 1
|
|
elif ch in ")]":
|
|
depth -= 1
|
|
elif depth == 0 and ch == ";":
|
|
break
|
|
elif depth == 0 and ch == "{":
|
|
body = k
|
|
break
|
|
k += 1
|
|
if body is None:
|
|
continue
|
|
end = match_brace(text, body)
|
|
owner = [t for s, e, t in impls if s < m.start() < e]
|
|
mod.funcs.append(Func(
|
|
module=mod.name, name=m.group(1), start=m.start(), end=end,
|
|
text=text[m.start():end], line=line_of(text, m.start()),
|
|
inline=inline_path(m.start()), impl_type=owner[-1] if owner else None,
|
|
is_test=m.start() in test_fns))
|
|
|
|
for m in USE_RE.finditer(text):
|
|
depth = len(inline_path(m.start()))
|
|
for path, alias in expand_use(m.group(1), []):
|
|
mod.uses.append(Use(path=path, alias=alias, depth=depth))
|
|
|
|
|
|
# ── Resolution ──────────────────────────────────────────────────────────────
|
|
|
|
class Tree:
|
|
"""The scanned modules and the call resolution between them."""
|
|
|
|
def __init__(self, modules: dict[str, Module]) -> None:
|
|
"""Index the scanned methods by name and by implementing type."""
|
|
self.modules = modules
|
|
self.methods: dict[str, list[Func]] = {}
|
|
self.by_type: dict[tuple[str, str], list[Func]] = {}
|
|
for mod in modules.values():
|
|
for f in mod.funcs:
|
|
if f.impl_type:
|
|
self.methods.setdefault(f.name, []).append(f)
|
|
self.by_type.setdefault((f.impl_type, f.name), []).append(f)
|
|
|
|
def module_for(self, base: str, depth: int, path: list[str]) -> str | None:
|
|
"""The scanned module a path names from inside `base`, or None.
|
|
|
|
`base` is a top-level test module, "" for mod.rs (node::tests itself)
|
|
or "testutil"; `depth` is how many inline `mod` blocks deep the path
|
|
is written, so `super` first climbs out of those.
|
|
"""
|
|
if not path:
|
|
return base
|
|
head, rest = path[0], path[1:]
|
|
if head == "crate":
|
|
if rest[:1] == ["testutil"] and "testutil" in self.modules:
|
|
return self.module_for("testutil", 0, rest[1:])
|
|
if rest[:2] == ["node", "tests"]:
|
|
return self.module_for("", 0, rest[2:])
|
|
return None
|
|
if head == "self":
|
|
return self.module_for(base, depth, rest)
|
|
if head == "super":
|
|
if depth > 0:
|
|
return self.module_for(base, depth - 1, rest)
|
|
if base in ("", "testutil"):
|
|
return None
|
|
return self.module_for("", 0, rest)
|
|
if base == "" and depth == 0 and head in self.modules and head != "testutil":
|
|
return self.module_for(head, 0, rest)
|
|
return None
|
|
|
|
def lookup(self, mod_name: str, name: str, seen: set[tuple[str, str]]) -> list[Func]:
|
|
"""Free functions `name` resolves to inside module `mod_name`."""
|
|
if (mod_name, name) in seen or mod_name not in self.modules:
|
|
return []
|
|
seen.add((mod_name, name))
|
|
mod = self.modules[mod_name]
|
|
own = [f for f in mod.funcs if f.name == name and not f.impl_type]
|
|
if own:
|
|
return own
|
|
found: list[Func] = []
|
|
for use in mod.uses:
|
|
if use.alias == name:
|
|
target = self.module_for(mod_name, use.depth, use.path[:-1])
|
|
if target is not None:
|
|
found += self.lookup(target, use.path[-1], seen)
|
|
if found:
|
|
return found
|
|
for use in mod.uses:
|
|
if use.alias is None:
|
|
target = self.module_for(mod_name, use.depth, use.path)
|
|
if target is not None:
|
|
found += self.lookup(target, name, seen)
|
|
return found
|
|
|
|
def callees(self, f: Func) -> list[Func]:
|
|
"""Every scanned function `f` may call."""
|
|
out: list[Func] = []
|
|
depth = len(f.inline)
|
|
visible = self.modules[f.module].text
|
|
after_name = f.text.index(f.name) + len(f.name)
|
|
for m in CALL_RE.finditer(f.text, after_name):
|
|
dot, qual, name = m.group(1), m.group(2), m.group(3)
|
|
if dot:
|
|
out += [c for c in self.methods.get(name, [])
|
|
if re.search(r"\b" + c.impl_type + r"\b", visible)]
|
|
continue
|
|
path = [s for s in re.split(r"\s*::\s*", qual.strip()) if s]
|
|
if not path:
|
|
out += self.lookup(f.module, name, set())
|
|
continue
|
|
target = self.module_for(f.module, depth, path)
|
|
if target is not None:
|
|
out += self.lookup(target, name, set())
|
|
elif path[-1] == "Self" and f.impl_type:
|
|
out += self.by_type.get((f.impl_type, name), [])
|
|
else:
|
|
out += self.by_type.get((path[-1], name), [])
|
|
for m in FN_ARG_RE.finditer(f.text, after_name):
|
|
out += self.lookup(f.module, m.group(1), set())
|
|
return [c for c in out if c is not f]
|
|
|
|
|
|
CALL_RE = re.compile(r"(\.\s*)?((?:" + IDENT + r"\s*::\s*)*)(" + IDENT +
|
|
r")\s*(?:::\s*<[^()]*?>\s*)?\(")
|
|
FN_ARG_RE = re.compile(r"[(,]\s*(" + IDENT + r")\s*(?=[),])")
|
|
|
|
|
|
# ── Loading ─────────────────────────────────────────────────────────────────
|
|
|
|
MOD_DECL_RE = re.compile(
|
|
r"((?:#\[[^\]]*\]\s*)*)(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+(" + IDENT + r")\s*;")
|
|
PATH_ATTR_RE = re.compile(r'#\[\s*path\s*=\s*"([^"]*)"\s*\]')
|
|
|
|
|
|
def load_tree(root: Path) -> Tree:
|
|
"""Read src/node/tests/mod.rs, the modules it declares, and src/testutil.rs."""
|
|
tests_dir = root / "src" / "node" / "tests"
|
|
mod_rs = tests_dir / "mod.rs"
|
|
try:
|
|
raw = mod_rs.read_text()
|
|
except OSError as e:
|
|
raise CannotCheck(f"cannot read {mod_rs}: {e}") from e
|
|
stripped = strip_rust(raw)
|
|
modules: dict[str, Module] = {"": Module("", mod_rs, stripped)}
|
|
for m in MOD_DECL_RE.finditer(stripped):
|
|
attrs_raw = raw[m.start(1):m.end(1)]
|
|
name = m.group(2)
|
|
path_m = PATH_ATTR_RE.search(attrs_raw)
|
|
if path_m:
|
|
candidates = [tests_dir / path_m.group(1)]
|
|
else:
|
|
candidates = [tests_dir / f"{name}.rs", tests_dir / name / "mod.rs"]
|
|
found = next((p for p in candidates if p.is_file()), None)
|
|
if found is None:
|
|
raise CannotCheck(f"mod.rs declares `mod {name};` but none of "
|
|
f"{', '.join(str(p) for p in candidates)} exists")
|
|
modules[name] = Module(name, found, strip_rust(found.read_text()))
|
|
if len(modules) == 1:
|
|
raise CannotCheck(f"{mod_rs} declares no test modules")
|
|
testutil = root / "src" / "testutil.rs"
|
|
if testutil.is_file():
|
|
modules["testutil"] = Module("testutil", testutil, strip_rust(testutil.read_text()))
|
|
for mod in modules.values():
|
|
scan(mod)
|
|
return Tree(modules)
|
|
|
|
|
|
@dataclass
|
|
class Exemption:
|
|
"""A primitive use in one function that opens no socket."""
|
|
|
|
key: str
|
|
primitive: str
|
|
reason: str
|
|
|
|
|
|
def load_exemptions(path: Path) -> list[Exemption]:
|
|
"""Read the exemption file; a missing file means no exemptions."""
|
|
if not path.is_file():
|
|
return []
|
|
try:
|
|
data = tomllib.loads(path.read_text())
|
|
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
raise CannotCheck(f"cannot read exemptions {path}: {e}") from e
|
|
out = []
|
|
known = {name for name, _ in PRIMITIVES}
|
|
for entry in data.get("exempt", []):
|
|
key, prim, reason = entry.get("fn"), entry.get("primitive"), entry.get("reason")
|
|
if not key or prim not in known or not (reason or "").strip():
|
|
raise CannotCheck(f"{path}: exemption {entry!r} needs fn, a primitive "
|
|
f"from {sorted(known)}, and a non-empty reason")
|
|
out.append(Exemption(key, prim, reason.strip()))
|
|
return out
|
|
|
|
|
|
# ── Flagging ────────────────────────────────────────────────────────────────
|
|
|
|
def flag(tree: Tree, exemptions: list[Exemption]) -> None:
|
|
"""Flag socket users and lock holders, each to a fixpoint."""
|
|
exempt = {(e.key, e.primitive) for e in exemptions}
|
|
funcs = [f for mod in tree.modules.values() for f in mod.funcs]
|
|
calls = {id(f): tree.callees(f) for f in funcs}
|
|
for f in funcs:
|
|
for prim, pattern in PRIMITIVES:
|
|
if (f.key, prim) in exempt:
|
|
continue
|
|
hit = pattern.search(f.text)
|
|
if hit:
|
|
f.flagged = f"uses {prim} (line {f.line + f.text.count(chr(10), 0, hit.start())})"
|
|
break
|
|
if re.search(r"\b" + LOCK_FN + r"\s*\(", f.text) and f.name != LOCK_FN:
|
|
f.large = f"calls {LOCK_FN}()"
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for f in funcs:
|
|
for c in calls[id(f)]:
|
|
if f.flagged is None and c.flagged is not None:
|
|
f.flagged = f"calls {c.key}"
|
|
changed = True
|
|
if f.large is None and c.large is not None and c.name != LOCK_FN:
|
|
f.large = f"calls {c.key}"
|
|
changed = True
|
|
|
|
|
|
# ── Config ──────────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class Override:
|
|
"""A test-group override, parsed into a matcher."""
|
|
|
|
group: str
|
|
pkg: str
|
|
names: list[str]
|
|
regex: re.Pattern
|
|
|
|
def matches(self, pkg: str, test: str) -> bool:
|
|
"""Whether nextest would apply this override to the test."""
|
|
return pkg == self.pkg and self.regex.search(test) is not None
|
|
|
|
|
|
def parse_filter(group: str, flt: str, where: str) -> Override:
|
|
"""Parse one of the two accepted filter shapes, or raise CannotCheck."""
|
|
shape = {GROUP_SOCKET: SYNTH_RE, GROUP_LARGE: LARGE_RE}.get(group)
|
|
m = shape.fullmatch(flt) if shape else None
|
|
if not m:
|
|
raise CannotCheck(
|
|
f"{where}: filter {flt!r} for test-group {group!r} is not a shape this "
|
|
"check can evaluate; expected "
|
|
"package(<pkg>) & test(/^node::tests::(m1|m2)::/) for node-synthetic, "
|
|
"package(<pkg>) & test(/^node::tests::(m::t1|m::t2)$/) for node-large")
|
|
regex_src = re.search(r"test\(/(.*)/\)$", flt).group(1)
|
|
return Override(group, m.group("pkg"), m.group("alts").split("|"), re.compile(regex_src))
|
|
|
|
|
|
def load_config(path: Path) -> tuple[dict[str, list[Override]], str, list[str]]:
|
|
"""Read both profiles' test-group overrides, in order, and the pinned version."""
|
|
try:
|
|
data = tomllib.loads(path.read_text())
|
|
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
raise CannotCheck(f"cannot read {path}: {e}") from e
|
|
problems = []
|
|
groups = data.get("test-groups", {})
|
|
for g in (GROUP_SOCKET, GROUP_LARGE):
|
|
if g not in groups:
|
|
raise CannotCheck(f"{path}: [test-groups] has no {g}")
|
|
if groups[g].get("max-threads") != 1:
|
|
problems.append(f"{path}: test group {g} must have max-threads = 1, "
|
|
f"has {groups[g].get('max-threads')!r}")
|
|
version = (data.get("nextest-version") or {})
|
|
required = version.get("required") if isinstance(version, dict) else version
|
|
if not isinstance(required, str) or not SEMVER_RE.match(required):
|
|
raise CannotCheck(f"{path}: nextest-version.required must be an exact "
|
|
f"version, found {required!r}")
|
|
profiles = {}
|
|
for p in PROFILES:
|
|
overrides = []
|
|
for i, o in enumerate(data.get("profile", {}).get(p, {}).get("overrides", [])):
|
|
if "test-group" not in o:
|
|
continue
|
|
where = f"{path} [profile.{p}] override {i + 1}"
|
|
if "filter" not in o or "platform" in o:
|
|
raise CannotCheck(f"{where}: a test-group override must have a filter "
|
|
"and no platform for this check to evaluate it")
|
|
overrides.append(parse_filter(o["test-group"], o["filter"], where))
|
|
if not any(o.group == GROUP_SOCKET for o in overrides):
|
|
raise CannotCheck(f"{path}: [profile.{p}] has no {GROUP_SOCKET} override")
|
|
if not any(o.group == GROUP_LARGE for o in overrides):
|
|
raise CannotCheck(f"{path}: [profile.{p}] has no {GROUP_LARGE} override")
|
|
profiles[p] = overrides
|
|
return profiles, required, problems
|
|
|
|
|
|
def check_pins(root: Path, required: str) -> list[str]:
|
|
"""Every nextest install step in the workflows pins the required version."""
|
|
wf_dir = root / ".github" / "workflows"
|
|
steps, problems = 0, []
|
|
for wf in sorted(wf_dir.glob("*.y*ml")):
|
|
for n, line in enumerate(wf.read_text().splitlines(), 1):
|
|
m = re.match(r"^\s*tool:\s*(.+?)\s*$", line)
|
|
if not m:
|
|
continue
|
|
for tool in (t.strip() for t in m.group(1).split(",")):
|
|
if re.match(r"^(cargo-)?nextest(@|$)", tool):
|
|
steps += 1
|
|
if tool.split("@", 1)[-1] != required or "@" not in tool:
|
|
problems.append(
|
|
f"{wf.relative_to(root)}:{n}: installs {tool!r}; pin it to "
|
|
f"nextest@{required}, the version .config/nextest.toml requires")
|
|
if steps == 0:
|
|
raise CannotCheck(f"no `tool: nextest` install step in {wf_dir}; "
|
|
"cannot check the pinned version")
|
|
return problems
|
|
|
|
|
|
def first_group(overrides: list[Override], pkg: str, test: str) -> str | None:
|
|
"""The group nextest would assign: the first matching override's."""
|
|
return next((o.group for o in overrides if o.matches(pkg, test)), None)
|
|
|
|
|
|
# ── Main ────────────────────────────────────────────────────────────────────
|
|
|
|
def package_name(root: Path) -> str:
|
|
"""The root package's name from Cargo.toml."""
|
|
try:
|
|
return tomllib.loads((root / "Cargo.toml").read_text())["package"]["name"]
|
|
except (OSError, KeyError, tomllib.TOMLDecodeError) as e:
|
|
raise CannotCheck(f"cannot read the package name from {root / 'Cargo.toml'}: {e}") from e
|
|
|
|
|
|
def survey(tree: Tree, flagged_mods: dict[str, Func], holders: list[Func]) -> None:
|
|
"""Print the membership and the reason each module is in it."""
|
|
test_mods = [m for m in tree.modules if m not in ("", "testutil")]
|
|
print(f"test modules declared: {len(test_mods)}")
|
|
print(f"node-synthetic ({len(flagged_mods)}):")
|
|
for name in sorted(flagged_mods):
|
|
f = flagged_mods[name]
|
|
print(f" {name}: {f.key} {f.flagged}")
|
|
print(f"not flagged ({len(test_mods) - len(flagged_mods)}): "
|
|
+ " ".join(sorted(m for m in test_mods if m not in flagged_mods)))
|
|
print(f"node-large ({len(holders)}):")
|
|
for f in holders:
|
|
print(f" {f.test_name[len(TESTS_PREFIX):]}: {f.large}")
|
|
helpers = [f for mod in tree.modules.values() for f in mod.funcs
|
|
if f.flagged and not f.is_test]
|
|
print(f"flagged helpers ({len(helpers)}):")
|
|
for f in helpers:
|
|
print(f" {f.key}: {f.flagged}")
|
|
|
|
|
|
def run(root: Path, exemptions_path: Path, show_survey: bool) -> int:
|
|
"""Run the check against the tree at `root`; return the exit status."""
|
|
pkg = package_name(root)
|
|
tree = load_tree(root)
|
|
exemptions = load_exemptions(exemptions_path)
|
|
flag(tree, exemptions)
|
|
keys = {f.key for mod in tree.modules.values() for f in mod.funcs}
|
|
notes = [f"note: exemption for {e.key} matches no function in this tree"
|
|
for e in exemptions if e.key not in keys]
|
|
|
|
flagged_mods: dict[str, Func] = {}
|
|
for name, mod in tree.modules.items():
|
|
if name in ("", "testutil"):
|
|
continue
|
|
hit = next((f for f in mod.funcs if f.flagged), None)
|
|
if hit:
|
|
flagged_mods[name] = hit
|
|
holders = [f for mod in tree.modules.values() for f in mod.funcs
|
|
if f.is_test and f.large]
|
|
if show_survey:
|
|
survey(tree, flagged_mods, holders)
|
|
if not flagged_mods:
|
|
raise CannotCheck("no test module is flagged as using a socket; the scan "
|
|
"has stopped seeing the primitives, so it proves nothing")
|
|
if not holders:
|
|
raise CannotCheck(f"no test calls {LOCK_FN}(); the lock was renamed or "
|
|
"moved, so node-large cannot be checked")
|
|
|
|
profiles, required, problems = load_config(root / ".config" / "nextest.toml")
|
|
problems += check_pins(root, required)
|
|
|
|
root_tests = [f for f in tree.modules[""].funcs if f.is_test and (f.flagged or f.large)]
|
|
for f in root_tests:
|
|
problems.append(f"{f.test_name} is a flagged test in mod.rs itself; a module "
|
|
"filter cannot place it, so move it into a test module")
|
|
|
|
for p, overrides in profiles.items():
|
|
listed = {n for o in overrides if o.group == GROUP_SOCKET for n in o.names}
|
|
for name, mod in tree.modules.items():
|
|
if name in ("", "testutil"):
|
|
continue
|
|
tests = [f for f in mod.funcs if f.is_test]
|
|
if name in flagged_mods:
|
|
why = flagged_mods[name]
|
|
for f in tests:
|
|
want = GROUP_LARGE if f.large else GROUP_SOCKET
|
|
got = first_group(overrides, pkg, f.test_name)
|
|
if got != want:
|
|
problems.append(
|
|
f"[profile.{p}] {f.test_name} runs in {got or 'no group'}, "
|
|
f"needs {want}: module {name} is flagged ({why.key} {why.flagged})")
|
|
elif name in listed and p == "ci":
|
|
notes.append(f"note: {name} is named in {GROUP_SOCKET} but not flagged "
|
|
"in this tree (another trunk may flag it)")
|
|
for f in holders:
|
|
if f.module in flagged_mods:
|
|
continue # checked above
|
|
got = first_group(overrides, pkg, f.test_name)
|
|
if got != GROUP_LARGE:
|
|
problems.append(f"[profile.{p}] {f.test_name} runs in {got or 'no group'}, "
|
|
f"needs {GROUP_LARGE}: it {f.large}")
|
|
|
|
for n in notes:
|
|
print(n)
|
|
tests_in = sum(1 for m in flagged_mods for f in tree.modules[m].funcs if f.is_test)
|
|
if problems:
|
|
for msg in problems:
|
|
print(f"FAIL {msg}")
|
|
print(f"check-node-test-groups: {len(problems)} finding(s)")
|
|
return 1
|
|
print(f"check-node-test-groups: {len(flagged_mods)} socket module(s) "
|
|
f"({tests_in} tests) in {GROUP_SOCKET}, {len(holders)} lock holder(s) in "
|
|
f"{GROUP_LARGE}, both profiles; nextest pinned to {required}")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
"""Parse arguments and run the check."""
|
|
here = Path(__file__).resolve().parent
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--root", type=Path, default=here.parent,
|
|
help="tree to check (default: this repository)")
|
|
ap.add_argument("--exemptions", type=Path, default=None,
|
|
help="exemption file (default: <root>/testing/node-test-groups/"
|
|
"exemptions.toml)")
|
|
ap.add_argument("--survey", action="store_true",
|
|
help="print the membership and each module's first reason")
|
|
args = ap.parse_args()
|
|
exemptions = args.exemptions or args.root / "testing" / "node-test-groups" / "exemptions.toml"
|
|
try:
|
|
return run(args.root, exemptions, args.survey)
|
|
except CannotCheck as e:
|
|
print(f"CANNOT CHECK {e}")
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|