Files
doris/build-support/check-unity-skip-coverage.py
Mingyu Chen (Rayner)andClaude Fable 5 7933a82bdd [opt](build) Enforce BE compile-time hygiene at configure time (#66901)
### What problem does this PR solve?

Related PR: #66510 (the BE build-time series, #66615..#66826, that this
PR locks in)

Problem Summary:

The build-time series cut BE cold builds by ~49% and took
`olap_common.h` / `uid_util.h` off the everything-rebuilds line. Every
one of those wins can regress **silently**: a stray include, a lost
`extern template`, a header added to the PCH — the code still compiles,
only every build afterwards is slower, and nothing reports it. `#66052`
already showed the failure mode: the layering rules in
`check-header-deps.py` only worked because the author happened to
maintain them by hand; nothing in CI runs them.

This PR turns each known regression vector into a **fail-loud,
seconds-level gate that runs at configure time**, so a violation is a
build error on every path that builds the BE (CI, `build.sh`, the UT
build) with zero extra CI wiring — the same mounting pattern the FE side
already uses for its architecture gates (`exec-maven-plugin` running
`check-fe-core-metadata-funnel.sh` at `validate`).

**Checks** (pure text scans, ~1.3s combined, zero build dependency, zero
false positives on current master):

| Gate | Regression it stops |
|---|---|
| Header layering rules (30, +6 new) | a hub header (e.g. `exec_env.h`,
`function.h`, `pch.h`) re-growing an include edge into a subsystem that
was deliberately cut |
| Third-party ban table (new) |
`fmt`/`boost`/`concurrentqueue.h`/`<ranges>` returning to headers whose
bodies were moved out of line to contain template-instantiation cost
(`<ranges>` in a src header also breaks the `-fno-access-control` UT
build on libc++) |
| Two-axis budgets (new) | the edges nobody names: light hubs must stay
light (include-closure budget, zero slack), heavy payloads must not
spread (transitive-reach baseline +10%); `--closure` / `--reach`
subcommands rank the offending edges for the fix |
| pch whitelist (new) | any quoted include added to `pch/pch.h` — the
single most leveraged regression in the repo (touch one such header and
the whole backend plus the PCH rebuild) |
| `check-extern-template-pairing.py` (new) | the one **silent**
extern-template failure: a `.cpp` keeps the explicit instantiation but
the header loses the `extern` declaration, so every TU quietly
re-instantiates; also the loud direction (declaration with no
definition), reported at once instead of at link |
| `check-unity-skip-coverage.py` (new) | a test `#include`-ing a src
`.cpp` that is not opted out of unity batching — reported at configure
with the exact skip entry to add, instead of as duplicate symbols at the
end of the BE UT link |

`build-support/check-build-hygiene.sh` runs all three scripts;
`be/CMakeLists.txt` executes it at configure time. Escape hatches, by
design:

- `-DENABLE_BUILD_HYGIENE=OFF` (default ON; a missing `python3` is a
loud FATAL naming this switch, never a silent skip);
- every budget/whitelist/rule table lives in the scripts themselves, so
the legal way past a gate is a **one-line reviewed diff in the same PR**
— the gate is never a dead end. Every failure message carries the
mechanism (why this edge is expensive) and the concrete fix path.

**Found by the new gates on day one, fixed here:** `core/types.h` has
carried five `extern template struct fmt::formatter<Decimal*>;`
declarations with **no matching definition anywhere** since #53483 —
every member is defined in-class, so the linker never noticed; deleted.
Also made the FE funnel self-test's `sed -i` invocation portable to
BSD/macOS.

Blind spot, by design: instantiations expanded from macros (e.g.
`DECLARE_OPERATOR` in `operator.cpp`) are invisible to the pairing scan
on both sides — macro bodies are skipped; a comment at the site says so.

`be/README.md` (new) documents the gates, what to do when each fires,
and the `*_fwd.h` convention as the sanctioned way through a layering
barrier.

The same rules are encoded for AI-assisted development and review:
`be/AGENTS.md` (new — include discipline while writing BE code + review
checkpoints), `build-support/AGENTS.md` (new — maintenance discipline
for the gate scripts themselves), an include-src-cpp checkpoint in
`be/test/AGENTS.md`, and a Header Hygiene entry in the root `AGENTS.md`,
so coding agents and the review pipeline apply them automatically.

### Release note

None

### Check List (For Author)

- Test
    - [x] Manual test (add detailed scripts or steps below)
- `build-support/check-build-hygiene.sh` on master: all green, ~1.3s,
zero false positives.
- Self-tests `bash build-support/tests/run.sh` (7/7 PASS): red/green
injection per gate family — each seeded violation turns red with the
mechanism + fix in the message, restoring the file turns it green again.
- Real configure on macOS arm64: green path passes the gate section;
with an injected violation, configure dies at the gate in under a second
with the full message; `-DENABLE_BUILD_HYGIENE=OFF` bypasses; a missing
`python3` is a loud exit-2 FATAL naming the switch.
- This PR's own Linux CI exercises the configure mount on the second
platform.

- Behavior changed:
- [x] Yes. BE configure now fails fast (with mechanism + fix in the
message) on header-hygiene violations; emergency bypass via
`-DENABLE_BUILD_HYGIENE=OFF`. No runtime behavior change — the only
source-code changes are comments and the deletion of five never-defined
`extern template` declarations.

- Does this need documentation?
- [x] No. Developer-facing docs are included in-repo (`be/README.md`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyZL631YTeuNXa9WLjvk7s

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:07:58 +08:00

227 lines
9.1 KiB
Python
Executable 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.
"""Unity-skip coverage for test-included source files.
A few tests `#include` a be/src .cpp directly (to reach file-static helpers or
to instantiate a template with test types). That only links because the source
file is opted out of unity batching: if it stays in a batch, the batch object is
pulled into the doris_be_test link for a *sibling's* symbol and its copy of the
file's definitions collides with the test's inlined copy -- duplicate strong
symbols, reported at the end of the BE UT build, an hour after the mistake.
This script reports the mistake at configure time instead, with the exact skip
entry to add.
It is the mirror of the fail-loud check inside doris_skip_unity_inclusion()
(be/CMakeLists.txt): that one catches skip entries pointing at files that no
longer exist; this one catches files that need an entry and do not have one.
A source file counts as covered when its owning CMakeLists (nearest ancestor
with a CMakeLists.txt, else any CMakeLists naming it) does one of:
literal entry the file's path appears in a list fed to
doris_skip_unity_inclusion(), via set()/list(APPEND)/direct
arguments;
whole-target the skip list is initialized from the target's own source
opt-out list (`set(<VAR> ${SRC_FILES})`), minus any
`list(FILTER <VAR> EXCLUDE REGEX ...)` the file matches;
no unity the owning CMakeLists never sets UNITY_BUILD, so there are
no batches to collide with (archive link rules protect
plain object files).
Any other CMake idiom is unknown to this script on purpose: it fails loudly so
the idiom is either replaced with a literal entry or taught here, instead of
being silently guessed wrong.
Usage:
build-support/check-unity-skip-coverage.py
"""
import os
import re
import sys
TEST_ROOT = "be/test"
SRC_ROOT = "be/src"
TEST_INCLUDE = re.compile(r'^\s*#\s*include\s+"([^"]+\.(?:cpp|cc))"')
SKIP_CALL = re.compile(r"doris_skip_unity_inclusion\s*\(([^)]*)\)", re.S)
SET_BLOCK = re.compile(r"\bset\s*\(\s*(\w+)([^)]*)\)", re.S)
APPEND_BLOCK = re.compile(r"\blist\s*\(\s*APPEND\s+(\w+)([^)]*)\)", re.S)
FILTER_BLOCK = re.compile(
r'\blist\s*\(\s*FILTER\s+(\w+)\s+EXCLUDE\s+REGEX\s+"([^"]*)"\s*\)'
)
VAR_REF = re.compile(r"\$\{(\w+)\}")
def strip_comments(text):
return "\n".join(line.split("#", 1)[0] for line in text.splitlines())
def cpp_tokens(blob):
"""Path-shaped tokens in a CMake argument blob, ${...} prefixes stripped."""
tokens = []
for token in blob.split():
if not token.endswith((".cpp", ".cc")):
continue
tokens.append(re.sub(r"\$\{\w+\}", "", token).lstrip("/"))
return tokens
class CMakeFile:
def __init__(self, path):
self.path = path
self.directory = os.path.dirname(path)
with open(path, encoding="utf-8", errors="ignore") as handle:
text = strip_comments(handle.read())
self.sets_unity = "UNITY_BUILD" in text
sets = {}
for name, blob in SET_BLOCK.findall(text):
sets.setdefault(name, []).append(blob)
for name, blob in APPEND_BLOCK.findall(text):
sets.setdefault(name, []).append(blob)
filters = {}
for name, regex in FILTER_BLOCK.findall(text):
filters.setdefault(name, []).append(regex)
# Literal skip entries and whether the skip list is seeded from the
# target's whole source list.
self.entries = []
self.whole_target = False
self.whole_target_excludes = []
for blob in SKIP_CALL.findall(text):
self.entries.extend(cpp_tokens(blob))
for var in VAR_REF.findall(blob):
for var_blob in sets.get(var, ()):
self.entries.extend(cpp_tokens(var_blob))
if re.search(r"\$\{\w*SRC_FILES\}", var_blob):
self.whole_target = True
self.whole_target_excludes.extend(filters.get(var, ()))
def resolved_entries(self):
"""Skip entries as repo-relative paths (handles ../ hops)."""
for entry in self.entries:
yield os.path.normpath(os.path.join(self.directory, entry))
def covers(self, repo_path):
"""Does this CMakeLists skip-list `repo_path` (repo-relative .cpp)?"""
for resolved in self.resolved_entries():
if resolved == repo_path or resolved.endswith("/" + repo_path):
return True
if self.whole_target and repo_path.startswith(self.directory + "/"):
# cmake applies FILTER regexes to absolute paths; approximate with
# the repo-relative one, which shares every path component that the
# tree's regexes (".*/format_v2/.*") anchor on.
return not any(
re.search(regex, repo_path)
for regex in self.whole_target_excludes
)
return False
def owning_cmakelists(repo_path, cmake_files):
directory = os.path.dirname(repo_path)
while directory.startswith(SRC_ROOT):
candidate = os.path.join(directory, "CMakeLists.txt")
if candidate in cmake_files:
return cmake_files[candidate]
directory = os.path.dirname(directory)
return None
def test_included_sources():
"""(test file, line, be/src-relative include) for every src .cpp a test
includes."""
found = []
for directory, _, names in os.walk(TEST_ROOT):
for name in names:
if not name.endswith((".cpp", ".cc", ".h", ".hpp")):
continue
path = os.path.join(directory, name)
with open(path, encoding="utf-8", errors="ignore") as handle:
for lineno, line in enumerate(handle, 1):
match = TEST_INCLUDE.match(line)
if not match:
continue
include = match.group(1)
if os.path.exists(os.path.join(SRC_ROOT, include)):
found.append((path, lineno, include))
return found
def main():
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(root)
cmake_files = {}
for directory, _, names in os.walk(SRC_ROOT):
if "CMakeLists.txt" in names:
path = os.path.join(directory, "CMakeLists.txt")
cmake_files[path] = CMakeFile(path)
failures = 0
checked = 0
for test_path, lineno, include in test_included_sources():
checked += 1
repo_path = os.path.join(SRC_ROOT, include)
owner = owning_cmakelists(repo_path, cmake_files)
if owner is not None and not owner.sets_unity:
continue # no unity batches in this target, nothing to collide with
if owner is not None and owner.covers(repo_path):
continue
if any(f.covers(repo_path) for f in cmake_files.values()):
continue # cross-directory entry (e.g. appended into a sibling's list)
failures += 1
print(
f"error: {test_path}:{lineno} includes {repo_path}, which is not "
"opted out of unity batching",
file=sys.stderr,
)
print(
" reason: the unity batch object containing this file gets pulled "
"into the doris_be_test link for a sibling's symbol, and its "
"definitions collide with the test's inlined copy -- duplicate "
"strong symbols at the end of the BE UT build",
file=sys.stderr,
)
if owner is not None:
entry = os.path.relpath(repo_path, owner.directory)
print(
f" fix: add ${{CMAKE_CURRENT_SOURCE_DIR}}/{entry} to the "
f"doris_skip_unity_inclusion list in {owner.path}",
file=sys.stderr,
)
else:
print(
" fix: add the file to the doris_skip_unity_inclusion "
"list of the CMakeLists whose source glob compiles it; for "
"the cross-directory entry form, see the adbc_reader.cpp "
"entry in be/src/format/CMakeLists.txt",
file=sys.stderr,
)
if failures:
print(f"\n{failures} unity-skip coverage violation(s)", file=sys.stderr)
return 1
print(f"unity-skip coverage: {checked} test-included source file(s) all covered")
return 0
if __name__ == "__main__":
sys.exit(main())