Skip to content

Commit 2ad0f58

Browse files
j-piaseckimeta-codesync[bot]
authored andcommitted
Log tier boundary breaks in the C++ API snapshot generator (#58835)
Summary: Pull Request resolved: #58835 Adds a header tier classifier to the C++ API snapshot generator. A header's tier comes from the stable API guard it includes; headers without one are unclassified. With the new `--log-boundary-breaks` flag, the generator logs every public header that reaches a frameworks or private header, and every frameworks header that reaches a private one, with the include chain. Snapshot output is unchanged. Changelog: [Internal] Differential Revision: D123001749
1 parent 1bf4945 commit 2ad0f58

3 files changed

Lines changed: 459 additions & 0 deletions

File tree

‎scripts/cxx-api/parser/__main__.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
from .main import build_snapshot
2626
from .path_utils import get_react_native_dir
2727
from .snapshot_diff import validate_snapshots
28+
from .tiers import classify_headers, find_boundary_breaks
29+
30+
# Mirrors the fixed EXCLUDE_PATTERNS in .doxygen.config.template.
31+
_TEMPLATE_EXCLUDE_PATTERNS = ["*/tests/*", "*/samples/*"]
2832

2933

3034
def run_command(
@@ -142,6 +146,47 @@ def build_snapshot_for_view(
142146
return snapshot_string
143147

144148

149+
def _display_path(path: str, react_native_dir: str) -> str:
150+
relative = os.path.relpath(path, react_native_dir)
151+
return path if relative.startswith("..") else relative
152+
153+
154+
def log_boundary_breaks(
155+
configs: list[ApiViewSnapshotConfig],
156+
codegen_dirs: dict[str, str],
157+
react_native_dir: str,
158+
enabled: bool,
159+
) -> None:
160+
if not enabled:
161+
return
162+
163+
# Variants of a view differ only in preprocessor definitions, which the
164+
# textual include scan ignores, so classify each distinct input set once.
165+
groups: dict[tuple, list[str]] = {}
166+
for config in configs:
167+
codegen_dir = codegen_dirs.get(config.codegen_platform)
168+
key = (
169+
tuple(config.inputs + ([codegen_dir] if codegen_dir else [])),
170+
tuple(config.exclude_patterns),
171+
)
172+
groups.setdefault(key, []).append(config.snapshot_name)
173+
174+
for (inputs, exclude_patterns), view_names in groups.items():
175+
label = ", ".join(view_names)
176+
graph = classify_headers(
177+
list(inputs), _TEMPLATE_EXCLUDE_PATTERNS + list(exclude_patterns)
178+
)
179+
breaks = find_boundary_breaks(graph)
180+
print(f"[{label}] {len(breaks)} tier boundary break(s)")
181+
for boundary_break in breaks:
182+
chain = [_display_path(p, react_native_dir) for p in boundary_break.chain]
183+
print(
184+
f" {boundary_break.source_tier.name.lower()} {chain[0]}"
185+
+ "".join(f"\n -> {p}" for p in chain[1:-1])
186+
+ f"\n -> {boundary_break.target_tier.name.lower()} {chain[-1]}"
187+
)
188+
189+
145190
def build_snapshots(
146191
snapshot_configs: list[ApiViewSnapshotConfig],
147192
react_native_dir: str,
@@ -151,6 +196,7 @@ def build_snapshots(
151196
view_filter: str | None = None,
152197
is_test: bool = False,
153198
keep_xml: bool = False,
199+
log_breaks: bool = False,
154200
) -> None:
155201
if not is_test:
156202
configs_to_build = [
@@ -177,6 +223,10 @@ def build_snapshots(
177223
label=platform,
178224
)
179225

226+
log_boundary_breaks(
227+
configs_to_build, codegen_dirs, react_native_dir, log_breaks
228+
)
229+
180230
with concurrent.futures.ThreadPoolExecutor() as executor:
181231
futures = {}
182232
for config in configs_to_build:
@@ -285,6 +335,11 @@ def main():
285335
action="store_true",
286336
help="Keep the generated Doxygen XML files next to the .api output in a xml/ directory",
287337
)
338+
parser.add_argument(
339+
"--log-boundary-breaks",
340+
action="store_true",
341+
help="Log headers whose includes cross the C++ stable API tier boundaries",
342+
)
288343
args = parser.parse_args()
289344

290345
verbose = not args.validate
@@ -343,6 +398,7 @@ def main():
343398
view_filter=args.view,
344399
is_test=args.test,
345400
keep_xml=args.xml,
401+
log_breaks=args.log_boundary_breaks,
346402
)
347403

348404
if args.validate:

‎scripts/cxx-api/parser/tiers.py‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
"""
7+
Header tier classification for the C++ stable API visibility model.
8+
9+
Each header declares its tier by including one of the guards from
10+
`react/cxxstableapi/`. Headers without a guard are unclassified.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import enum
16+
import fnmatch
17+
import os
18+
import re
19+
from collections import deque
20+
from dataclasses import dataclass
21+
22+
23+
class Tier(enum.IntEnum):
24+
PRIVATE = 1
25+
FRAMEWORKS = 2
26+
PUBLIC = 3
27+
28+
29+
_GUARD_TIERS = {
30+
"Private": Tier.PRIVATE,
31+
"Frameworks": Tier.FRAMEWORKS,
32+
"Umbrella": Tier.PUBLIC,
33+
}
34+
35+
_GUARD_RE = re.compile(
36+
r'^\s*#\s*include\s*[<"]react/cxxstableapi/(Private|Frameworks|Umbrella)Guard\.h[>"]',
37+
re.MULTILINE,
38+
)
39+
40+
_INCLUDE_RE = re.compile(
41+
r'^\s*#\s*(?:include|import)\s*([<"])([^>"]+)[>"]',
42+
re.MULTILINE,
43+
)
44+
45+
_GUARD_DIR = os.sep + os.path.join("react", "cxxstableapi") + os.sep
46+
47+
48+
@dataclass
49+
class HeaderGraph:
50+
# header path -> tier
51+
tiers: dict[str, Tier | None]
52+
# header path -> its direct includes
53+
includes: dict[str, list[str]]
54+
55+
56+
@dataclass(frozen=True)
57+
class BoundaryBreak:
58+
# From the declared header that starts the walk to the header it must not
59+
# reach; everything in between is unclassified.
60+
chain: tuple[str, ...]
61+
source_tier: Tier
62+
target_tier: Tier
63+
64+
65+
def collect_headers(input_dirs: list[str], exclude_patterns: list[str]) -> list[str]:
66+
"""
67+
Walk the input directories for `*.h` files, skipping any whose absolute
68+
path matches an exclude pattern (Doxygen `EXCLUDE_PATTERNS` semantics).
69+
"""
70+
headers = set()
71+
for input_dir in input_dirs:
72+
for dirpath, _, filenames in os.walk(input_dir):
73+
for filename in filenames:
74+
if not filename.endswith(".h"):
75+
continue
76+
path = os.path.abspath(os.path.join(dirpath, filename))
77+
if any(fnmatch.fnmatchcase(path, p) for p in exclude_patterns):
78+
continue
79+
headers.add(path)
80+
return sorted(headers)
81+
82+
83+
def _read_tier(content: str) -> Tier | None:
84+
match = _GUARD_RE.search(content)
85+
return _GUARD_TIERS[match.group(1)] if match else None
86+
87+
88+
def _resolve_include(
89+
includer: str,
90+
delimiter: str,
91+
includee: str,
92+
headers: set[str],
93+
by_basename: dict[str, list[str]],
94+
) -> list[str]:
95+
# #include "header.h"
96+
if delimiter == '"':
97+
sibling = os.path.normpath(os.path.join(os.path.dirname(includer), includee))
98+
# The compiler picks an existing sibling even when this view excludes
99+
# it, so falling back to a same-named header elsewhere would be wrong.
100+
if os.path.isfile(sibling):
101+
return [sibling] if sibling in headers else []
102+
103+
# #include <header.h> or fall through
104+
suffix = os.sep + includee
105+
return [
106+
path
107+
for path in by_basename.get(os.path.basename(includee), [])
108+
if path.endswith(suffix)
109+
]
110+
111+
112+
def build_header_graph(headers: list[str]) -> HeaderGraph:
113+
# The guard headers declare nothing, so every header that includes one
114+
# would otherwise gain an edge into them.
115+
headers = [path for path in headers if _GUARD_DIR not in path]
116+
header_set = set(headers)
117+
by_basename: dict[str, list[str]] = {}
118+
for path in headers:
119+
by_basename.setdefault(os.path.basename(path), []).append(path)
120+
121+
tiers: dict[str, Tier | None] = {}
122+
includes: dict[str, list[str]] = {}
123+
for path in headers:
124+
with open(path, encoding="utf-8", errors="replace") as f:
125+
content = f.read()
126+
tiers[path] = _read_tier(content)
127+
resolved: list[str] = []
128+
for delimiter, includee in _INCLUDE_RE.findall(content):
129+
for target in _resolve_include(
130+
path, delimiter, includee, header_set, by_basename
131+
):
132+
if target != path and target not in resolved:
133+
resolved.append(target)
134+
includes[path] = resolved
135+
136+
return HeaderGraph(tiers=tiers, includes=includes)
137+
138+
139+
def classify_headers(input_dirs: list[str], exclude_patterns: list[str]) -> HeaderGraph:
140+
return build_header_graph(collect_headers(input_dirs, exclude_patterns))
141+
142+
143+
def find_boundary_breaks(graph: HeaderGraph) -> list[BoundaryBreak]:
144+
"""
145+
Find every declared public or frameworks header that reaches a less
146+
visible declared header, directly or through unclassified headers.
147+
148+
The walk stops at declared headers: whatever a declared header reaches is
149+
reported from that header's own walk, so each break is reported once, at
150+
the edge where visibility actually drops.
151+
"""
152+
breaks: list[BoundaryBreak] = []
153+
for source_tier in (Tier.PUBLIC, Tier.FRAMEWORKS):
154+
sources = sorted(
155+
path for path, tier in graph.tiers.items() if tier == source_tier
156+
)
157+
for source in sources:
158+
parents: dict[str, str | None] = {source: None}
159+
queue = deque([source])
160+
161+
while queue:
162+
node = queue.popleft()
163+
for target in graph.includes.get(node, []):
164+
if target in parents:
165+
continue
166+
parents[target] = node
167+
target_tier = graph.tiers.get(target)
168+
if target_tier is None:
169+
queue.append(target)
170+
elif target_tier < source_tier:
171+
breaks.append(
172+
BoundaryBreak(
173+
chain=_chain_to(target, parents),
174+
source_tier=source_tier,
175+
target_tier=target_tier,
176+
)
177+
)
178+
return breaks
179+
180+
181+
def _chain_to(target: str, parents: dict[str, str | None]) -> tuple[str, ...]:
182+
chain = []
183+
node: str | None = target
184+
while node is not None:
185+
chain.append(node)
186+
node = parents[node]
187+
return tuple(reversed(chain))

0 commit comments

Comments
 (0)