mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-15 13:10:45 +00:00
54ce32cddf
solidcore-step, SolidCraft's STEP reader (moved in by #13979), joins CRATES in check_panic_sites.py. (The scan's clippy run already copes with solidcore-record, a dependency of step's outside CRATES, through main's `--cap-lints warn` from #14194.) solidcore-step's 75 sites are listed in plan/panic-sites.tsv, all proven, each with its reason. Fixed first: - eval.rs `expanded`: the knots are built only for more control points than the degree, with multiplicities adding up to points + degree + 1, checked before anything is built. A file can no longer have the knot vector grow to a size it declares: the knots are at most twice the points, which are already in memory. - read.rs `implied_knots`: a degree of 0 is refused before the Bezier segments divide by it. - topology.rs `face_domain`: a natural range that leaves no room for the box (crossed, or not a number) is refused, naming it, before the clamp. A range narrower than the box's span takes its own start exactly, where `n1 - span` could round below `n0` and panic the clamp. - write/topology.rs `outer_loop`: the ring grows as it is filled, rather than reserving edges x samples. Main's other rows are left as they are. The scan notices 13 that no longer match a site (main's own drift), and that doesn't fail the check. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
667 lines
27 KiB
Python
Vendored
667 lines
27 KiB
Python
Vendored
#!/usr/bin/env python3
|
|
"""Fails if library code gains a panic-prone call that the deny lints don't see (#2490).
|
|
|
|
The workspace denies unwrap/expect/panic/indexing. What they miss: integer `/` and `%` (panic
|
|
on a zero divisor), and std calls that panic on a bad index, length or bound (`Vec::remove`,
|
|
`insert`, `chunks`/`windows`, `f64::clamp`, `swap`, `split_at`, ...) or on capacity overflow
|
|
(`with_capacity`). Each such call in non-test library code of the crates applications use
|
|
(CRATES) is listed in plan/panic-sites.tsv. A new site fails this check, unless a mechanical
|
|
rule (AUTO_PROVEN: a literal `windows(2)`, `clamp(-1.0, 1.0)`, divisor) proves it cannot
|
|
panic. A listed site that is gone is only reported: removing a site never fails a build, and
|
|
the next `--update` drops its row.
|
|
|
|
Sites come from one `cargo clippy --lib --message-format=json` run, so every receiver type
|
|
is resolved: a `.remove(` on a `BTreeMap` (returns Option) is not a `Vec::remove`. The method
|
|
calls are clippy's `disallowed_methods` with the paths in METHODS (in a temporary clippy.toml,
|
|
so the normal clippy run is unaffected); `/` and `%` are `integer_division_remainder_used`,
|
|
which fires on integer operands only, and `/=`, `%=` the matching `arithmetic_side_effects`
|
|
sites (see DIVISION). Overflow (the rest of `arithmetic_side_effects`, ~10k sites) is not
|
|
listed: CI's tests run with overflow checks on, so an overflow they reach already fails.
|
|
|
|
A row is `file, fn, pattern, n, status, code, reason`. `fn` is the enclosing function path
|
|
(`Type::method`, `outer::inner`; `-` outside any function) and `n` counts the pattern's sites
|
|
within it in source order, so the key survives edits elsewhere in the file. `code` is the
|
|
call's text. `status` is `open`, or `proven` with a `reason` when the site cannot panic
|
|
(constant non-zero divisor, length checked just above); proven rows are exempt from the
|
|
ratchet, but their `code` must still match, so a mark cannot slide onto another call.
|
|
With --base, an `open` row must already be open in the base list (by key, or by pattern
|
|
and code when the function moved), so a new site cannot be listed as open to pass.
|
|
|
|
Usage: check_panic_sites.py [--base <rev>] [--update] [--clippy-json <file>] [<repo root>]
|
|
--update rewrite the list from the tree: drops gone sites, adds new ones as open
|
|
(or proven, for the mechanical rules in AUTO_PROVEN), keeps marks.
|
|
--clippy-json read clippy's JSON messages from a file instead of running cargo.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections import Counter, defaultdict
|
|
from pathlib import Path
|
|
|
|
LIST = Path("plan/panic-sites.tsv")
|
|
# solidcore and the STEP crates SolidCraft links (solidcore-step: SolidCraft's STEP reader,
|
|
# moved in by tangency-labs/solidcraft#13979). nurbs-grid-mesh is not here: nothing depends on
|
|
# it, and docs/layers.md has it merged into solidcore-tess and then deleted.
|
|
CRATES = ["solidcore", "step-export", "solidcore-step"]
|
|
COLUMNS = ["file", "fn", "pattern", "n", "status", "code", "reason"]
|
|
|
|
# clippy `disallowed-methods` path -> pattern name. Only calls that can panic on their
|
|
# arguments; the panic-free namesakes (maps, sets, `VecDeque::remove`) are other paths.
|
|
METHODS = {
|
|
"alloc::vec::Vec::remove": "Vec::remove",
|
|
"alloc::vec::Vec::insert": "Vec::insert",
|
|
"alloc::vec::Vec::swap_remove": "Vec::swap_remove",
|
|
"alloc::vec::Vec::drain": "Vec::drain",
|
|
"alloc::vec::Vec::split_off": "Vec::split_off",
|
|
"alloc::vec::Vec::with_capacity": "with_capacity",
|
|
"alloc::string::String::with_capacity": "with_capacity",
|
|
"alloc::collections::VecDeque::with_capacity": "with_capacity",
|
|
"alloc::string::String::remove": "String::remove",
|
|
"alloc::string::String::insert": "String::insert",
|
|
"alloc::string::String::insert_str": "String::insert",
|
|
"alloc::string::String::truncate": "String::truncate",
|
|
"alloc::collections::VecDeque::insert": "VecDeque::insert",
|
|
"alloc::collections::VecDeque::swap": "swap",
|
|
"slice::swap": "swap",
|
|
"slice::chunks": "chunks",
|
|
"slice::chunks_mut": "chunks",
|
|
"slice::chunks_exact": "chunks",
|
|
"slice::chunks_exact_mut": "chunks",
|
|
"slice::rchunks": "chunks",
|
|
"slice::windows": "windows",
|
|
"slice::split_at": "split_at",
|
|
"slice::split_at_mut": "split_at",
|
|
"slice::copy_from_slice": "copy_from_slice",
|
|
"slice::clone_from_slice": "copy_from_slice",
|
|
"slice::rotate_left": "rotate",
|
|
"slice::rotate_right": "rotate",
|
|
"f64::clamp": "f64::clamp",
|
|
"f32::clamp": "f64::clamp",
|
|
"core::cmp::Ord::clamp": "Ord::clamp",
|
|
"core::iter::Iterator::step_by": "step_by",
|
|
"usize::div_ceil": "int-div",
|
|
"u32::div_ceil": "int-div",
|
|
"u64::div_ceil": "int-div",
|
|
"usize::div_euclid": "int-div",
|
|
"i32::div_euclid": "int-div",
|
|
"i64::div_euclid": "int-div",
|
|
"usize::rem_euclid": "int-rem",
|
|
"i32::rem_euclid": "int-rem",
|
|
"i64::rem_euclid": "int-rem",
|
|
}
|
|
LINTS = [
|
|
"clippy::disallowed_methods",
|
|
"clippy::integer_division_remainder_used",
|
|
"clippy::arithmetic_side_effects",
|
|
]
|
|
# integer_division_remainder_used names the operator in its message: "use of `%` has been
|
|
# disallowed". It fires on integer operands only (std's Div/Rem impls), not on floats, but
|
|
# not on `/=` and `%=` either. Those come from arithmetic_side_effects, which skips floats and
|
|
# a non-zero literal divisor; the only other type with DivAssign/RemAssign is Vec3, allowed.
|
|
DIVISION = {"/": "int-div", "%": "int-rem"}
|
|
COMPOUND = re.compile(r"^[^=]*?([/%])=")
|
|
SIDE_EFFECTS_ALLOWED = ["Vec3", "geom::vector3d::Vec3", "solidcore::geom::vector3d::Vec3"]
|
|
|
|
INT = r"(?:_?(?:u8|u16|u32|u64|usize|i8|i16|i32|i64|isize))?"
|
|
LIT = r"[1-9][0-9_]*" + INT
|
|
FLT = r"-?[0-9][0-9_]*(?:\.[0-9_]*)?(?:e-?[0-9]+)?(?:_?f64|_?f32)?"
|
|
# A product or sum of positive literals, e.g. `(4 * 32)`: non-zero, and rustc rejects an
|
|
# overflowing constant expression at compile time.
|
|
CONST_EXPR = r"\(\s*" + LIT + r"(?:\s*[*+]\s*" + LIT + r")*\s*\)"
|
|
CONST_NAME = r"(?:Self::)?([A-Z][A-Z0-9_]*)"
|
|
# Mechanical proofs from the call's text alone, applied by --update to new rows.
|
|
AUTO_PROVEN = [
|
|
("int-div", r"/=?\s*(?:" + LIT + "|" + CONST_EXPR + r")$", "constant non-zero divisor"),
|
|
("int-rem", r"%=?\s*(?:" + LIT + "|" + CONST_EXPR + r")$", "constant non-zero divisor"),
|
|
("int-div", r"/=?\s*[\w.]+\.max\(" + LIT + r"\)$", "divisor is max(.., >= 1)"),
|
|
("int-rem", r"%=?\s*[\w.]+\.max\(" + LIT + r"\)$", "divisor is max(.., >= 1)"),
|
|
("chunks", r"\(" + LIT + r"\)$", "constant non-zero size"),
|
|
("windows", r"\(" + LIT + r"\)$", "constant non-zero size"),
|
|
("step_by", r"\(" + LIT + r"\)$", "constant non-zero step"),
|
|
("with_capacity", r"\((?:" + LIT + r"|0" + INT + r")\)$", "constant capacity"),
|
|
("Vec::insert", r"^insert\(0" + INT + r",", "index 0 is never past the end"),
|
|
]
|
|
CLAMP = re.compile(r"\(\s*(" + FLT + r")\s*,\s*(" + FLT + r")\s*\)$")
|
|
DIVISOR_CONST = re.compile(r"[/%]=?\s*" + CONST_NAME + r"$")
|
|
|
|
|
|
def const_value(src, name):
|
|
"""The value of `const NAME: <int> = <literal>;` if `src` defines NAME exactly once."""
|
|
defs = re.findall(
|
|
r"\bconst\s+" + name + r"\s*:\s*[iu](?:8|16|32|64|size)\s*=\s*([0-9][0-9_]*)" + INT + r"\s*;",
|
|
src,
|
|
)
|
|
# every binding of the name, function and closure parameters included: a parameter
|
|
# that shadows the constant makes the proof unsound (no scope resolution here)
|
|
names = re.findall(
|
|
r"\b(?:const|static|let(?:\s+mut)?)\s+" + name + r"\b"
|
|
r"|[(,|]\s*(?:mut\s+)?" + name + r"\s*:",
|
|
src,
|
|
)
|
|
return int(defs[0].replace("_", "")) if len(defs) == 1 and len(names) == 1 else None
|
|
|
|
|
|
def auto_reason(pattern, code, src=""):
|
|
"""The reason a site provably cannot panic, from its text alone, or None."""
|
|
# `x /= n / 2`: a compound assignment's divisor is all the text after its operator, so
|
|
# the proof must match from the operator on, not at the last `/` (the divisor's own)
|
|
op = COMPOUND.match(code) if pattern in ("int-div", "int-rem") else None
|
|
text, find = (code[op.start(1):], re.match) if op else (code, re.search)
|
|
for pat, rx, reason in AUTO_PROVEN:
|
|
if pat == pattern and find(rx, text):
|
|
return reason
|
|
m = find(DIVISOR_CONST, text) if pattern in ("int-div", "int-rem") else None
|
|
if m and const_value(src, m.group(1)):
|
|
return f"divisor is the non-zero constant {m.group(1)}"
|
|
m = CLAMP.search(code) if pattern in ("f64::clamp", "Ord::clamp") else None
|
|
if m:
|
|
lo, hi = (float(re.sub(r"_|f64|f32", "", g)) for g in m.groups())
|
|
if lo <= hi:
|
|
return "constant bounds, min <= max"
|
|
return None
|
|
|
|
|
|
# ---- source structure: the function enclosing an offset -----------------------------------
|
|
|
|
|
|
def blank_literals(src):
|
|
"""`src` with comments, strings and char literals replaced by spaces (newlines kept)."""
|
|
out = list(src)
|
|
i, n = 0, len(src)
|
|
|
|
def blank(a, b):
|
|
for k in range(a, min(b, n)):
|
|
if out[k] != "\n":
|
|
out[k] = " "
|
|
|
|
while i < n:
|
|
c = src[i]
|
|
if src.startswith("//", i):
|
|
j = src.find("\n", i)
|
|
j = n if j < 0 else j
|
|
blank(i, j)
|
|
i = j
|
|
elif src.startswith("/*", i):
|
|
depth, j = 1, i + 2
|
|
while j < n and depth:
|
|
if src.startswith("/*", j):
|
|
depth, j = depth + 1, j + 2
|
|
elif src.startswith("*/", j):
|
|
depth, j = depth - 1, j + 2
|
|
else:
|
|
j += 1
|
|
blank(i, j)
|
|
i = j
|
|
elif (m := re.compile(r'(?:b|c)?r(#*)"').match(src, i)) and (
|
|
i == 0 or not (src[i - 1].isalnum() or src[i - 1] == "_")
|
|
):
|
|
end = src.find('"' + m.group(1), m.end())
|
|
j = n if end < 0 else end + 1 + len(m.group(1))
|
|
blank(i, j)
|
|
i = j
|
|
elif c == '"':
|
|
j = i + 1
|
|
while j < n and src[j] != '"':
|
|
j += 2 if src[j] == "\\" else 1
|
|
blank(i, j + 1)
|
|
i = j + 1
|
|
elif c == "'":
|
|
if src.startswith("\\", i + 1):
|
|
j = src.find("'", i + 3 if src.startswith("\\'", i + 1) else i + 2)
|
|
j = n if j < 0 else j
|
|
blank(i, j + 1)
|
|
i = j + 1
|
|
elif i + 2 < n and src[i + 2] == "'":
|
|
blank(i, i + 3)
|
|
i += 3
|
|
else:
|
|
i += 1 # a lifetime
|
|
else:
|
|
i += 1
|
|
return "".join(out)
|
|
|
|
|
|
ITEM = re.compile(r"\b(fn|impl|trait|mod)\b|[{};()\[\]]")
|
|
|
|
|
|
def impl_name(header):
|
|
"""`Type` for `impl<..> Trait for path::Type<..> where ..`."""
|
|
depth, flat = 0, []
|
|
for ch in header:
|
|
if ch == "<":
|
|
depth += 1
|
|
elif ch == ">":
|
|
depth -= 1
|
|
elif depth == 0:
|
|
flat.append(ch)
|
|
flat = re.split(r"\bwhere\b", "".join(flat))[0]
|
|
flat = re.split(r"\bfor\b", flat)[-1]
|
|
names = re.findall(r"[A-Za-z_]\w*", flat)
|
|
names = [x for x in names if x not in ("dyn", "mut", "const", "unsafe")]
|
|
return names[-1] if names else "impl"
|
|
|
|
|
|
def fn_spans(src):
|
|
"""(start, end, path) of every fn/impl/trait/mod body, outermost first."""
|
|
text = blank_literals(src)
|
|
stack, spans = [], []
|
|
pending, paren = None, 0
|
|
for m in ITEM.finditer(text):
|
|
tok = m.group(0)
|
|
if tok in "([":
|
|
paren += 1
|
|
elif tok in ")]":
|
|
paren -= 1
|
|
elif tok == "{":
|
|
if pending and pending[2] == paren:
|
|
stack.append((m.start(), pending[0], pending[1]))
|
|
else:
|
|
stack.append((m.start(), None, None))
|
|
pending = None
|
|
elif tok == "}":
|
|
if stack:
|
|
start, kind, name = stack.pop()
|
|
if kind:
|
|
names = [s[2] for s in stack if s[1]] + [name]
|
|
spans.append((start, m.end(), "::".join(names)))
|
|
elif tok == ";":
|
|
if pending and pending[2] == paren:
|
|
pending = None
|
|
elif pending and pending[0] == "fn":
|
|
continue # `impl Trait` / `fn(..)` types inside a signature
|
|
elif tok == "fn":
|
|
nm = re.compile(r"\s*([A-Za-z_]\w*)").match(text, m.end())
|
|
if nm:
|
|
pending = ("fn", nm.group(1), paren)
|
|
elif tok in ("trait", "mod"):
|
|
nm = re.compile(r"\s*([A-Za-z_]\w*)").match(text, m.end())
|
|
if nm:
|
|
pending = (tok, nm.group(1), paren)
|
|
elif tok == "impl":
|
|
brace = text.find("{", m.end())
|
|
semi = text.find(";", m.end())
|
|
if brace >= 0 and (semi < 0 or brace < semi):
|
|
pending = ("impl", impl_name(text[m.end() : brace]), paren)
|
|
spans.sort()
|
|
return spans
|
|
|
|
|
|
def enclosing(spans, offset):
|
|
best = None
|
|
for start, end, path in spans:
|
|
if start > offset:
|
|
break
|
|
if offset < end and (best is None or start >= best[0]):
|
|
best = (start, end, path)
|
|
return best[2] if best else "-"
|
|
|
|
|
|
# ---- clippy messages -> sites ----------------------------------------------------------------
|
|
|
|
def normalize(code):
|
|
"""`code` on one line, single-spaced, without parentheses around the whole of it."""
|
|
code = " ".join(code.split()).replace("( ", "(").replace(" )", ")")
|
|
while code.startswith("(") and code.endswith(")"):
|
|
depth = 0
|
|
for k, ch in enumerate(code):
|
|
depth += {"(": 1, ")": -1}.get(ch, 0)
|
|
if depth == 0 and k < len(code) - 1:
|
|
return code
|
|
code = code[1:-1].strip()
|
|
return code
|
|
|
|
|
|
def call_args(src, start):
|
|
"""The `(..)` (after an optional `::<..>`) that begins at byte `start` of `src`."""
|
|
i = start
|
|
if src.startswith(b"::<", i):
|
|
depth, i = 1, i + 3
|
|
while i < len(src) and depth:
|
|
depth += {ord("<"): 1, ord(">"): -1}.get(src[i], 0)
|
|
i += 1
|
|
if not src.startswith(b"(", i):
|
|
return b""
|
|
depth, j = 0, i
|
|
while j < len(src):
|
|
depth += {ord("("): 1, ord(")"): -1}.get(src[j], 0)
|
|
j += 1
|
|
if depth == 0:
|
|
return src[i:j]
|
|
return b""
|
|
|
|
|
|
def span_code(span, src):
|
|
"""The text of the span; for a method or function path, with its call's arguments."""
|
|
a, b = span["byte_start"], span["byte_end"]
|
|
return src[a:b] + call_args(src, b)
|
|
|
|
|
|
def library_span(span, prefixes):
|
|
"""The span itself, or the macro call site it was expanded from, inside library code."""
|
|
while span is not None:
|
|
# clippy names files with backslashes on Windows (#13623); the list and the prefixes use `/`
|
|
file = span["file_name"].replace("\\", "/")
|
|
if file.startswith(prefixes):
|
|
return dict(span, file_name=file)
|
|
exp = span.get("expansion")
|
|
span = exp["span"] if exp else None
|
|
return None
|
|
|
|
|
|
def raw_sites(messages, prefixes, read_bytes):
|
|
"""{(file, line, col, pattern): code} from clippy's JSON messages."""
|
|
sites = {}
|
|
for msg in messages:
|
|
if msg.get("reason") != "compiler-message":
|
|
continue
|
|
diag = msg["message"]
|
|
lint = (diag.get("code") or {}).get("code")
|
|
if lint not in LINTS:
|
|
continue
|
|
primary = next((s for s in diag["spans"] if s["is_primary"]), None)
|
|
span = library_span(primary, prefixes) if primary else None
|
|
if span is None:
|
|
continue
|
|
code = span_code(span, read_bytes(span["file_name"]))
|
|
code = normalize(code.decode("utf-8", "replace"))
|
|
m = re.search(r"`([^`]+)`", diag["message"])
|
|
if lint == "clippy::disallowed_methods":
|
|
pattern = METHODS.get(m.group(1)) if m else None
|
|
elif lint == "clippy::integer_division_remainder_used":
|
|
pattern = DIVISION.get(m.group(1)) if m else None
|
|
else:
|
|
op = COMPOUND.match(code)
|
|
pattern = DIVISION.get(op.group(1)) if op else None
|
|
if pattern:
|
|
sites[(span["file_name"], span["line_start"], span["column_start"], pattern)] = code
|
|
return sites
|
|
|
|
|
|
def keyed_sites(raw, read_source):
|
|
"""[row dict] with fn path and occurrence index, sorted by key."""
|
|
by_file = defaultdict(list)
|
|
for (file, line, col, pattern), code in raw.items():
|
|
by_file[file].append((line, col, pattern, code))
|
|
rows = []
|
|
for file, items in sorted(by_file.items()):
|
|
src = read_source(file)
|
|
starts = [0]
|
|
for ln in src.splitlines(keepends=True):
|
|
starts.append(starts[-1] + len(ln))
|
|
spans = fn_spans(src)
|
|
counts = Counter()
|
|
for line, col, pattern, code in sorted(items):
|
|
fn = enclosing(spans, starts[line - 1] + col - 1)
|
|
counts[(fn, pattern)] += 1
|
|
rows.append(
|
|
{"file": file, "fn": fn, "pattern": pattern, "n": str(counts[(fn, pattern)]),
|
|
"status": "open", "code": code, "reason": ""}
|
|
)
|
|
return rows
|
|
|
|
|
|
def run_clippy(root):
|
|
conf = (root / "clippy.toml").read_text()
|
|
if "disallowed-methods" in conf:
|
|
raise SystemExit("clippy.toml sets disallowed-methods; merge it into METHODS here first")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
paths = ",\n".join(f' "{p}"' for p in METHODS)
|
|
allowed = ", ".join(f'"{t}"' for t in SIDE_EFFECTS_ALLOWED)
|
|
Path(tmp, "clippy.toml").write_text(
|
|
f"{conf}\ndisallowed-methods = [\n{paths},\n]\n"
|
|
f"arithmetic-side-effects-allowed = [{allowed}]\n"
|
|
)
|
|
cmd = ["cargo", "clippy", "--lib", "--message-format=json"]
|
|
for crate in CRATES:
|
|
cmd += ["-p", crate]
|
|
# a renamed or misspelt lint would otherwise pass with no sites at all
|
|
# --cap-lints warn: a workspace crate a listed crate depends on but this list does not
|
|
# cover (solidcore-record, which denies disallowed_methods in its own source) is checked
|
|
# too, and its denies must not fail the run; its sites are not rows.
|
|
cmd += ["--", "--cap-lints", "warn", "-D", "unknown_lints"]
|
|
cmd += [arg for lint in LINTS for arg in ("-W", lint)]
|
|
env = dict(os.environ, CLIPPY_CONF_DIR=tmp)
|
|
res = subprocess.run(cmd, cwd=root, env=env, stdout=subprocess.PIPE, text=True)
|
|
if res.returncode != 0:
|
|
raise SystemExit(f"cargo clippy failed ({res.returncode})")
|
|
return parse_messages(res.stdout)
|
|
|
|
|
|
def parse_messages(text):
|
|
out = []
|
|
for line in text.splitlines():
|
|
if line.startswith("{"):
|
|
out.append(json.loads(line))
|
|
unknown = [
|
|
m["message"]["message"] for m in out
|
|
if m.get("reason") == "compiler-message"
|
|
and "does not refer to" in m["message"]["message"]
|
|
]
|
|
if unknown:
|
|
raise SystemExit("clippy did not resolve a METHODS path: " + "; ".join(unknown))
|
|
lints = [
|
|
m["message"]["message"] for m in out
|
|
if m.get("reason") == "compiler-message"
|
|
and m["message"]["message"].startswith("unknown lint")
|
|
]
|
|
if lints:
|
|
raise SystemExit("clippy does not know a LINTS name: " + "; ".join(lints))
|
|
return out
|
|
|
|
|
|
# ---- the list ------------------------------------------------------------------------------
|
|
|
|
|
|
def key(row):
|
|
return (row["file"], row["fn"], row["pattern"], row["n"])
|
|
|
|
|
|
def read_list(text):
|
|
rows = []
|
|
for line in text.splitlines():
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
cells = line.split("\t")
|
|
if cells == COLUMNS:
|
|
continue
|
|
cells += [""] * (len(COLUMNS) - len(cells))
|
|
rows.append(dict(zip(COLUMNS, cells)))
|
|
return rows
|
|
|
|
|
|
HEADER = (
|
|
"# Panic-prone calls in library code that no deny lint catches (#2490). Written by\n"
|
|
"# scripts/ci/check_panic_sites.py --update; may only shrink. Fix a site with a typed\n"
|
|
"# SolidError (checked arithmetic, .get(), a length check) and drop its row. `proven` marks\n"
|
|
"# a site that cannot panic, with the reason; it is exempt from the ratchet.\n"
|
|
)
|
|
|
|
|
|
def write_list(rows):
|
|
lines = [HEADER, "\t".join(COLUMNS) + "\n"]
|
|
for r in sorted(rows, key=lambda r: (r["file"], r["fn"], r["pattern"], int(r["n"]))):
|
|
lines.append("\t".join(r[c] for c in COLUMNS) + "\n")
|
|
return "".join(lines)
|
|
|
|
|
|
def sig(row):
|
|
"""A site's identity for matching the tree with the list: its function, pattern and
|
|
call text, not its occurrence index `n`, which a site added or removed earlier in
|
|
the same function renumbers."""
|
|
return (row["file"], row["fn"], row["pattern"], row["code"])
|
|
|
|
|
|
def excess(rows, others):
|
|
"""The rows of `rows` beyond the count of their `sig` in `others` (the last ones of
|
|
each signature, in key order)."""
|
|
room = Counter(sig(r) for r in others)
|
|
seen = Counter()
|
|
out = []
|
|
for r in sorted(rows, key=key):
|
|
seen[sig(r)] += 1
|
|
if seen[sig(r)] > room[sig(r)]:
|
|
out.append(r)
|
|
return out
|
|
|
|
|
|
def check(current, listed, base=None, read_source=lambda f: ""):
|
|
"""Error messages: sites beyond the list's count of their call text in their function
|
|
that no mechanical rule proves, malformed rows, new open rows. Matching by call text
|
|
rather than by key means a renumbered site is never new, and a `proven` mark covers
|
|
only the call text it was written for."""
|
|
errors = []
|
|
for r in excess(current, listed):
|
|
if auto_reason(r["pattern"], r["code"], read_source(r["file"])) is not None:
|
|
continue
|
|
errors.append(
|
|
f"{r['file']}: new panic-prone call `{r['code']}` ({r['pattern']}) in {r['fn']}. "
|
|
"Return a typed SolidError instead (checked arithmetic, .get(), a length check). "
|
|
"If it provably cannot panic, run `python3 scripts/ci/check_panic_sites.py "
|
|
"--update` and mark its row in plan/panic-sites.tsv `proven` with the reason."
|
|
)
|
|
for r in listed:
|
|
k = key(r)
|
|
if r["status"] not in ("open", "proven"):
|
|
errors.append(f"{r['file']}: row {k} has status `{r['status']}`, not open/proven")
|
|
elif r["status"] == "proven" and not r["reason"].strip():
|
|
errors.append(f"{r['file']}: proven row {k} has no reason")
|
|
if base is not None:
|
|
errors += ratchet(listed, base)
|
|
return errors
|
|
|
|
|
|
def stale(current, listed):
|
|
"""A notice for rows that no longer match a site, or None."""
|
|
gone = excess(listed, current)
|
|
if not gone:
|
|
return None
|
|
names = ", ".join(f"{r['file']} {r['fn']} {r['pattern']} #{r['n']}" for r in gone[:5])
|
|
more = f" and {len(gone) - 5} more" if len(gone) > 5 else ""
|
|
return (
|
|
f"{len(gone)} row(s) of plan/panic-sites.tsv no longer match a site ({names}{more}); "
|
|
"`python3 scripts/ci/check_panic_sites.py --update` drops them."
|
|
)
|
|
|
|
|
|
def ratchet(listed, base):
|
|
"""Open rows that were not open in the base list: matched by `sig` as a multiset (so
|
|
a renumbering is no new row), then by pattern + call text among the leftovers (a
|
|
function or file moved or renamed)."""
|
|
base_open = [r for r in base if r["status"] == "open"]
|
|
new_open = excess([r for r in listed if r["status"] == "open"], base_open)
|
|
left = excess(base_open, [r for r in listed if r["status"] == "open"])
|
|
spare = Counter((r["pattern"], r["code"]) for r in left)
|
|
errors = []
|
|
for r in new_open:
|
|
if spare[(r["pattern"], r["code"])] > 0:
|
|
spare[(r["pattern"], r["code"])] -= 1
|
|
continue
|
|
errors.append(
|
|
f"{r['file']}: plan/panic-sites.tsv gains an open row ({r['fn']} {r['pattern']} "
|
|
f"`{r['code']}`). The list may only shrink: fix the call with a typed SolidError, "
|
|
"or mark it `proven` with the reason it cannot panic."
|
|
)
|
|
return errors
|
|
|
|
|
|
def update(current, listed, read_source=lambda f: ""):
|
|
"""The new list: current sites, keeping each mark whose code still matches, at its key
|
|
or (after a renumbering) at a site with the same code in the same function."""
|
|
lst = {key(r): r for r in listed if r["status"] == "proven"}
|
|
kept = {key(r) for r in current if key(r) in lst and lst[key(r)]["code"] == r["code"]}
|
|
marks = defaultdict(list)
|
|
for k, r in lst.items():
|
|
if k not in kept:
|
|
marks[(r["file"], r["fn"], r["pattern"], r["code"])].append(r)
|
|
out = []
|
|
for r in current:
|
|
r = dict(r)
|
|
same = (r["file"], r["fn"], r["pattern"], r["code"])
|
|
if key(r) in kept:
|
|
r["status"], r["reason"] = "proven", lst[key(r)]["reason"]
|
|
elif marks.get(same):
|
|
r["status"], r["reason"] = "proven", marks[same].pop(0)["reason"]
|
|
elif (reason := auto_reason(r["pattern"], r["code"], read_source(r["file"]))) is not None:
|
|
r["status"], r["reason"] = "proven", reason
|
|
out.append(r)
|
|
return out
|
|
|
|
|
|
def summary(rows):
|
|
by = defaultdict(Counter)
|
|
for r in rows:
|
|
by[r["pattern"]][r["status"]] += 1
|
|
width = max((len(p) for p in by), default=0)
|
|
lines = [f"{'pattern':<{width}} open proven"]
|
|
for p in sorted(by):
|
|
lines.append(f"{p:<{width}} {by[p]['open']:>4} {by[p]['proven']:>6}")
|
|
total = Counter(r["status"] for r in rows)
|
|
lines.append(f"{'total':<{width}} {total['open']:>4} {total['proven']:>6}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def base_list(root, rev):
|
|
git = ["git", "-C", str(root)]
|
|
if subprocess.run(git + ["cat-file", "-e", f"{rev}^{{commit}}"]).returncode != 0:
|
|
raise SystemExit(f"base revision {rev} is not in this clone (fetch it first)")
|
|
# git takes `/` on every system (#13623). `./` names the list relative to `root`, which is a
|
|
# folder of the repository inside SolidCraft (`solidcore/`); a base commit holding `root`'s
|
|
# tree alone (CI's, there) has it at the top.
|
|
for spec in (f"{rev}:./{LIST.as_posix()}", f"{rev}:{LIST.as_posix()}"):
|
|
res = subprocess.run(git + ["show", spec], stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL, text=True)
|
|
if res.returncode == 0:
|
|
return read_list(res.stdout)
|
|
return None
|
|
|
|
|
|
def main(argv):
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("root", nargs="?", default=".")
|
|
ap.add_argument("--base")
|
|
ap.add_argument("--update", action="store_true")
|
|
ap.add_argument("--clippy-json")
|
|
args = ap.parse_args(argv[1:])
|
|
root = Path(args.root)
|
|
if args.clippy_json:
|
|
messages = parse_messages(Path(args.clippy_json).read_text())
|
|
else:
|
|
messages = run_clippy(root)
|
|
prefixes = tuple(f"crates/{c}/src/" for c in CRATES)
|
|
sources = {}
|
|
|
|
def read_bytes(file):
|
|
if file not in sources:
|
|
sources[file] = (root / file).read_bytes()
|
|
return sources[file]
|
|
|
|
def read_source(file):
|
|
return read_bytes(file).decode("utf-8", "replace")
|
|
|
|
current = keyed_sites(raw_sites(messages, prefixes, read_bytes), read_source)
|
|
path = root / LIST
|
|
listed = read_list(path.read_text()) if path.exists() else []
|
|
if args.update:
|
|
rows = update(current, listed, read_source)
|
|
path.write_text(write_list(rows))
|
|
print(f"wrote {LIST}: {len(rows)} sites\n{summary(rows)}")
|
|
return 0
|
|
base = base_list(root, args.base) if args.base else None
|
|
errors = check(current, listed, base, read_source)
|
|
for e in errors:
|
|
print(f"::error file={LIST}::{e}")
|
|
if (notice := stale(current, listed)) is not None:
|
|
print(f"::notice file={LIST}::{notice}")
|
|
print(f"{LIST}:\n{summary(listed)}")
|
|
if base is None and args.base:
|
|
print(f"{args.base} has no {LIST}; the open-row ratchet starts with this list")
|
|
return 1 if errors else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|