blob: 9918e5760aeea0f6fc565dd795ca70f76fcdb6f8 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Parallel -fsyntax-only sweep over BE TUs using compile_commands.json.
Validates include-structure changes (header cuts, forward-declaration swaps)
against every TU without mutating the ninja build state: each compile command
is replayed with -o/-c/-MD/-MT/-MF/-ftime-trace* stripped and -fsyntax-only
appended, so nothing is written to the build directory.
Sweep scope: all first-party TUs -- standalone be/src entries AND unity
batches (CMake unity .cxx live under the build dir and would be silently
skipped by a be/src path filter; each batch textually includes its member
.cpp files, so checking the batch checks the members). Third-party contrib
(openblas/faiss/clucene/orc) and generated sources are excluded.
--no-pch strips the PCH preamble (-include-pch / forced cmake_pch.hxx
include) so every TU is checked against its NATURAL include closure. This is
the mode that catches "TU compiles only because the PCH leaks symbols it
never includes" debt -- mandatory before slimming pch.h, and the only mode
that works while a header included by pch.h has been edited but the .pch has
not been rebuilt.
Usage:
python3 syntax_sweep.py [--build-dir DIR] [--jobs N] [--filter SUBSTR]
[--no-pch] [--fail-list FILE] [--fail-log FILE]
[--timeout SECS]
Exit code: 0 if every TU passes, 1 otherwise.
"""
import argparse
import concurrent.futures
import json
import os
import re
import shlex
import subprocess
import sys
import time
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR))
ANSI = re.compile(r"\x1b\[[0-9;]*m")
STRIP_WITH_ARG = ("-o", "-MT", "-MF")
STRIP_FLAGS = ("-c", "-MD", "-MMD")
def mangle(cmd, no_pch=False):
args = shlex.split(cmd)
out = []
i = 0
while i < len(args):
a = args[i]
if a in STRIP_WITH_ARG:
i += 2
continue
if a in STRIP_FLAGS or a.startswith("-ftime-trace"):
i += 1
continue
if no_pch:
if a == "-Winvalid-pch":
i += 1
continue
# -Xclang -include-pch -Xclang <file.pch>
# -Xclang -include -Xclang <cmake_pch.hxx>
if (a == "-Xclang" and i + 3 < len(args)
and args[i + 1] in ("-include-pch", "-include")
and args[i + 2] == "-Xclang"
and ("cmake_pch" in args[i + 3]
or args[i + 3].endswith(".pch"))):
i += 4
continue
# plain -include <cmake_pch.hxx> form
if a == "-include" and i + 1 < len(args) and "cmake_pch" in args[i + 1]:
i += 2
continue
out.append(a)
i += 1
out.append("-fsyntax-only")
return out
def display_name(path, src_prefix):
if path.startswith(src_prefix):
return path[len(src_prefix):]
be_prefix = os.path.dirname(os.path.dirname(src_prefix)) + os.sep
if path.startswith(be_prefix + "test" + os.sep):
return path[len(be_prefix):]
if "/Unity/" in path:
# .../src/<dir>/CMakeFiles/<tgt>.dir/Unity/unity_N_cxx.cxx
m = re.search(r"/src/([^/]+)/CMakeFiles/[^/]+/Unity/(unity_\d+)", path)
if m:
return f"{m.group(1)}/[{m.group(2)}]"
return path
def first_party(e, src_prefix, test_prefix=None):
f = e["file"]
if f.startswith(src_prefix):
return True
if test_prefix and f.startswith(test_prefix):
return True
# CMake unity batches for first-party targets live under the build dir
return "/Unity/" in f and ("/src/" in f or (test_prefix and "/test/" in f))
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--build-dir",
default=os.path.join(REPO_ROOT, "be", "build_Release_compile_bench"))
ap.add_argument("--jobs", type=int, default=max(2, (os.cpu_count() or 8) // 2))
ap.add_argument("--filter", default="",
help="only sweep TUs whose path contains this substring")
ap.add_argument("--no-pch", action="store_true",
help="strip PCH preamble: check each TU's natural include closure")
ap.add_argument("--fail-list", default="",
help="write sorted display names of failing TUs to this file")
ap.add_argument("--fail-log", default="",
help="write full stderr of every failing TU to this file")
ap.add_argument("--timeout", type=int, default=600,
help="per-TU timeout in seconds (counts as failure)")
ap.add_argument("--include-tests", action="store_true",
help="also sweep be/test TUs (point --build-dir at a "
"-DMAKE_TEST=ON tree; be/test builds without a PCH, "
"so the UT line always compiles natural closures)")
args = ap.parse_args()
src_prefix = os.path.join(REPO_ROOT, "be", "src") + os.sep
test_prefix = (os.path.join(REPO_ROOT, "be", "test") + os.sep
if args.include_tests else None)
with open(os.path.join(args.build_dir, "compile_commands.json")) as f:
entries = [e for e in json.load(f)
if first_party(e, src_prefix, test_prefix)
and args.filter in e["file"]]
print(f"{len(entries)} TUs to check ({args.jobs} jobs"
f"{', natural closure / no PCH' if args.no_pch else ''})", flush=True)
t0 = time.time()
fails = []
done = 0
def run(e):
cmd = mangle(e["command"], no_pch=args.no_pch)
name = display_name(e["file"], src_prefix)
try:
p = subprocess.run(cmd, cwd=e["directory"],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, timeout=args.timeout)
return name, p.returncode, p.stderr
except subprocess.TimeoutExpired:
return name, 124, f"TIMEOUT after {args.timeout}s"
with concurrent.futures.ThreadPoolExecutor(args.jobs) as ex:
for name, rc, err in ex.map(run, entries):
done += 1
if rc != 0:
fails.append((name, err))
print(f"FAIL {name}", flush=True)
if done % 50 == 0:
print(f" ...{done}/{len(entries)} ({time.time() - t0:.0f}s, "
f"{len(fails)} failures)", flush=True)
print(f"\n==== {len(fails)} failing TU(s) of {len(entries)} "
f"in {time.time() - t0:.0f}s ====", flush=True)
if args.fail_list:
with open(args.fail_list, "w") as f:
for name, _ in sorted(fails):
f.write(name + "\n")
if args.fail_log and fails:
with open(args.fail_log, "w") as f:
for name, err in fails:
f.write(f"===== {name}\n{ANSI.sub('', err)}\n")
for name, err in fails[:15]:
first = [l for l in ANSI.sub("", err).splitlines() if " error: " in l][:2]
print(name)
for l in first:
print(" ", l[:200])
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())