Skip to content

Commit bedefd5

Browse files
j-piaseckifacebook-github-bot
authored andcommitted
Let the C++ API snapshot config select its visibility (#58838)
Summary: Adds a `visibility` option to the C++ API snapshot `config.yml`, listing which C++ stable API tiers (`public`, `frameworks`, `private`) a view includes. It can be set per view or at the top level, where it applies to views that do not set their own. When omitted it defaults to `public`, so existing snapshots are unchanged. Headers in tiers outside the selection are skipped unless a header in an included tier reaches them. Unclassified headers are still kept. Changelog: [Internal] Differential Revision: D123001791
1 parent 4e0e1bc commit bedefd5

6 files changed

Lines changed: 157 additions & 36 deletions

File tree

‎scripts/cxx-api/README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Python build pipeline for React Native's C++ (and Objective-C) API snapshots.
44

55
## Overview
66

7-
`scripts/cxx-api` generates human-readable snapshots of React Native's public C++ API surface. It uses [Doxygen](https://www.doxygen.nl/) to parse C/C++/Objective-C headers and a custom Python parser to produce a simplified, sorted representation of every public symbol. Symbols declared in headers that the C++ stable API classifies as private or "for frameworks" are left out (see [Tier filtering](#tier-filtering)).
7+
`scripts/cxx-api` generates human-readable snapshots of React Native's public C++ API surface. It uses [Doxygen](https://www.doxygen.nl/) to parse C/C++/Objective-C headers and a custom Python parser to produce a simplified, sorted representation of every public symbol. Symbols declared in headers that the C++ stable API classifies as private or "for frameworks" are left out by default (see [Tier filtering](#tier-filtering)).
88

99
The pipeline produces one `.api` snapshot file per configured **API view × variant** combination:
1010

@@ -76,7 +76,7 @@ The Python parser (`parser/`) reads the Doxygen XML output and builds a scope tr
7676

7777
## Tier filtering
7878

79-
A private or for-frameworks header is skipped unless a public header reaches it, directly or through other includes: anything a public header includes is public in practice, whatever its own guard says. Unclassified headers are never skipped.
79+
Each view includes the tiers listed in its `visibility` config (`public`, `frameworks`, `private`), only `public` by default. A header in any other tier is skipped unless a header in an included tier reaches it, directly or through other includes: anything a public header includes is public in practice, whatever its own guard says. Unclassified headers are never skipped.
8080

8181
A boundary break is a public header that reaches a for-frameworks or private header, or a for-frameworks header that reaches a private one. `--log-boundary-breaks` reports them at the edge where visibility drops.
8282

@@ -102,8 +102,9 @@ All API views and their variants are defined in `config.yml`. Each view specifie
102102
| `codegen` | Optional codegen platform (`android`, `ios`) to generate TurboModule/Component headers before scanning |
103103
| `exclude_symbols` | Regex patterns for symbols to skip |
104104
| `input_filter` | Whether to run Doxygen through the input filters in `parser/input_filters/` |
105+
| `visibility` | C++ stable API tiers to include (`public`, `frameworks`, `private`); defaults to `[public]`. See [Tier filtering](#tier-filtering) |
105106

106-
`exclude_patterns` and `exclude_symbols` can also be set at the top level, in which case they apply to every view.
107+
`exclude_patterns` and `exclude_symbols` can also be set at the top level, in which case they apply to every view. A top-level `visibility` applies to every view that does not set its own.
107108

108109
## Snapshot format
109110

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

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -163,67 +163,87 @@ def _display_path(path: str, react_native_dir: str) -> str:
163163
def classify_views(
164164
configs: list[ApiViewSnapshotConfig],
165165
codegen_dirs: dict[str, str],
166-
) -> list[tuple[list[str], HeaderGraph]]:
166+
) -> list[tuple[list[ApiViewSnapshotConfig], HeaderGraph]]:
167167
# Variants of a view differ only in preprocessor definitions, which the
168168
# textual include scan ignores, so classify each distinct input set once.
169-
groups: dict[tuple, list[str]] = {}
169+
groups: dict[tuple, list[ApiViewSnapshotConfig]] = {}
170170
for config in configs:
171171
codegen_dir = codegen_dirs.get(config.codegen_platform)
172172
key = (
173173
tuple(config.inputs + ([codegen_dir] if codegen_dir else [])),
174174
tuple(config.exclude_patterns),
175175
)
176-
groups.setdefault(key, []).append(config.snapshot_name)
176+
groups.setdefault(key, []).append(config)
177177

178178
return [
179179
(
180-
view_names,
180+
view_configs,
181181
classify_headers(
182182
list(inputs), _TEMPLATE_EXCLUDE_PATTERNS + list(exclude_patterns)
183183
),
184184
)
185-
for (inputs, exclude_patterns), view_names in groups.items()
185+
for (inputs, exclude_patterns), view_configs in groups.items()
186186
]
187187

188188

189189
def get_skipped_files_by_view(
190-
view_graphs: list[tuple[list[str], HeaderGraph]],
190+
view_graphs: list[tuple[list[ApiViewSnapshotConfig], HeaderGraph]],
191191
react_native_dir: str,
192192
verbose: bool,
193193
) -> dict[str, set[str]]:
194194
skipped_by_view: dict[str, set[str]] = {}
195-
for view_names, graph in view_graphs:
196-
skipped = skipped_headers(graph)
197-
if verbose:
198-
kept = sorted(
199-
path
200-
for path, tier in graph.tiers.items()
201-
if tier in (Tier.PRIVATE, Tier.FRAMEWORKS) and path not in skipped
202-
)
203-
print(
204-
f"[{', '.join(view_names)}] {len(kept)} private or frameworks "
205-
"header(s) kept because a public header reaches them"
195+
for view_configs, graph in view_graphs:
196+
names_by_visibility: dict[frozenset[Tier], list[str]] = {}
197+
for config in view_configs:
198+
names_by_visibility.setdefault(config.visibility, []).append(
199+
config.snapshot_name
206200
)
207-
for path in kept:
208-
print(
209-
f" {graph.tiers[path].name.lower()} "
210-
f"{_display_path(path, react_native_dir)}"
201+
202+
for visibility, view_names in names_by_visibility.items():
203+
skipped = skipped_headers(graph, visibility)
204+
if verbose:
205+
_log_kept_headers(
206+
view_names, graph, visibility, skipped, react_native_dir
211207
)
212-
for view_name in view_names:
213-
skipped_by_view[view_name] = skipped
208+
for view_name in view_names:
209+
skipped_by_view[view_name] = skipped
214210
return skipped_by_view
215211

216212

213+
def _log_kept_headers(
214+
view_names: list[str],
215+
graph: HeaderGraph,
216+
included_tiers: frozenset[Tier],
217+
skipped: set[str],
218+
react_native_dir: str,
219+
) -> None:
220+
kept = sorted(
221+
path
222+
for path, tier in graph.tiers.items()
223+
if tier is not None and tier not in included_tiers and path not in skipped
224+
)
225+
included = ", ".join(tier.name.lower() for tier in sorted(included_tiers))
226+
print(
227+
f"[{', '.join(view_names)}] {len(kept)} header(s) outside the included "
228+
f"tiers ({included}) kept because an included header reaches them"
229+
)
230+
for path in kept:
231+
print(
232+
f" {graph.tiers[path].name.lower()} "
233+
f"{_display_path(path, react_native_dir)}"
234+
)
235+
236+
217237
def log_boundary_breaks(
218-
view_graphs: list[tuple[list[str], HeaderGraph]],
238+
view_graphs: list[tuple[list[ApiViewSnapshotConfig], HeaderGraph]],
219239
react_native_dir: str,
220240
enabled: bool,
221241
) -> None:
222242
if not enabled:
223243
return
224244

225-
for view_names, graph in view_graphs:
226-
label = ", ".join(view_names)
245+
for view_configs, graph in view_graphs:
246+
label = ", ".join(config.snapshot_name for config in view_configs)
227247
breaks = find_boundary_breaks(graph)
228248
print(f"[{label}] {len(breaks)} tier boundary break(s)")
229249
for boundary_break in breaks:

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
import yaml
1717

18+
from .tiers import DEFAULT_TIERS, parse_tier, Tier
19+
1820

1921
@dataclass
2022
class ApiViewVariant:
@@ -39,6 +41,15 @@ class ApiViewSnapshotConfig:
3941
codegen_platform: str | None = None
4042
input_filter: bool = False
4143
exclude_symbols: list[str] = field(default_factory=list)
44+
visibility: frozenset[Tier] = DEFAULT_TIERS
45+
46+
47+
def _parse_visibility(
48+
names: list[str] | None, default: frozenset[Tier]
49+
) -> frozenset[Tier]:
50+
if names is None:
51+
return default
52+
return frozenset(parse_tier(name) for name in names)
4253

4354

4455
def parse_config(
@@ -51,6 +62,8 @@ def parse_config(
5162
The config must contain:
5263
- An optional top-level ``exclude_patterns`` list that is prepended to
5364
every platform's own ``exclude_patterns``.
65+
- An optional top-level ``visibility`` list of C++ stable API tiers,
66+
used by platforms that do not set their own. Defaults to ``[public]``.
5467
- A ``platforms`` mapping whose values are per-platform view configs.
5568
5669
Args:
@@ -62,6 +75,7 @@ def parse_config(
6275
"""
6376
global_exclude_patterns: list[str] = raw_config.get("exclude_patterns") or []
6477
global_exclude_symbols: list[str] = raw_config.get("exclude_symbols") or []
78+
global_visibility = _parse_visibility(raw_config.get("visibility"), DEFAULT_TIERS)
6579
platforms: dict = raw_config.get("platforms") or {}
6680

6781
snapshot_configs = []
@@ -95,6 +109,7 @@ def parse_config(
95109
merged_symbols.append(symbol)
96110
seen_symbols.add(symbol)
97111
exclude_symbols = merged_symbols
112+
visibility = _parse_visibility(view_config.get("visibility"), global_visibility)
98113

99114
raw_variants = view_config.get("variants") or {}
100115
variants = [
@@ -115,6 +130,7 @@ def parse_config(
115130
codegen_platform=codegen_platform,
116131
input_filter=input_filter,
117132
exclude_symbols=exclude_symbols,
133+
visibility=visibility,
118134
)
119135
)
120136
else:
@@ -130,6 +146,7 @@ def parse_config(
130146
codegen_platform=codegen_platform,
131147
input_filter=input_filter,
132148
exclude_symbols=exclude_symbols,
149+
visibility=visibility,
133150
)
134151
)
135152

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class Tier(enum.IntEnum):
2626
PUBLIC = 3
2727

2828

29+
DEFAULT_TIERS: frozenset[Tier] = frozenset({Tier.PUBLIC})
30+
2931
_GUARD_TIERS = {
3032
"Private": Tier.PRIVATE,
3133
"Frameworks": Tier.FRAMEWORKS,
@@ -178,12 +180,23 @@ def find_boundary_breaks(graph: HeaderGraph) -> list[BoundaryBreak]:
178180
return breaks
179181

180182

181-
def skipped_headers(graph: HeaderGraph) -> set[str]:
183+
def parse_tier(name: str) -> Tier:
184+
try:
185+
return Tier[name.upper()]
186+
except KeyError:
187+
valid = ", ".join(tier.name.lower() for tier in Tier)
188+
raise ValueError(f"Unknown tier '{name}', expected one of: {valid}") from None
189+
190+
191+
def skipped_headers(
192+
graph: HeaderGraph, included_tiers: frozenset[Tier] = DEFAULT_TIERS
193+
) -> set[str]:
182194
"""
183-
Private and frameworks headers that no public header reaches. A header
184-
reachable from a public one is public in practice, whatever its guard says.
195+
Classified headers outside `included_tiers` that no header in an included
196+
tier reaches. A header reachable from an included one is effectively part
197+
of that tier, whatever its guard says.
185198
"""
186-
reachable = {path for path, tier in graph.tiers.items() if tier == Tier.PUBLIC}
199+
reachable = {path for path, tier in graph.tiers.items() if tier in included_tiers}
187200
queue = deque(reachable)
188201
while queue:
189202
node = queue.popleft()
@@ -195,7 +208,7 @@ def skipped_headers(graph: HeaderGraph) -> set[str]:
195208
return {
196209
path
197210
for path, tier in graph.tiers.items()
198-
if tier in (Tier.PRIVATE, Tier.FRAMEWORKS) and path not in reachable
211+
if tier is not None and tier not in included_tiers and path not in reachable
199212
}
200213

201214

‎scripts/cxx-api/tests/test_config.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import unittest
99

1010
from ..parser.config import parse_config
11+
from ..parser.tiers import Tier
1112

1213

1314
class TestParseConfig(unittest.TestCase):
@@ -625,6 +626,54 @@ def test_per_platform_exclude_symbols_propagated_to_variants(self):
625626
for r in result:
626627
self.assertEqual(r.exclude_symbols, ["Fantom", "Android"])
627628

629+
# =========================================================================
630+
# Visibility
631+
# =========================================================================
632+
633+
def test_visibility_defaults_to_public(self):
634+
"""Missing visibility defaults to public only"""
635+
result = parse_config({"platforms": {"TestView": {}}}, "/base/dir")
636+
637+
self.assertEqual(result[0].visibility, frozenset({Tier.PUBLIC}))
638+
639+
def test_per_platform_visibility_overrides_global(self):
640+
"""Per-platform visibility replaces the global one"""
641+
config = {
642+
"visibility": ["public", "frameworks"],
643+
"platforms": {
644+
"ViewA": {"visibility": ["private"]},
645+
"ViewB": {},
646+
},
647+
}
648+
result = parse_config(config, "/base/dir")
649+
650+
view_a = next(r for r in result if r.snapshot_name == "ViewA")
651+
self.assertEqual(view_a.visibility, frozenset({Tier.PRIVATE}))
652+
653+
view_b = next(r for r in result if r.snapshot_name == "ViewB")
654+
self.assertEqual(view_b.visibility, frozenset({Tier.PUBLIC, Tier.FRAMEWORKS}))
655+
656+
def test_visibility_propagated_to_variants(self):
657+
"""Visibility applies to every variant of a view"""
658+
config = {
659+
"platforms": {
660+
"TestView": {
661+
"visibility": ["public", "private"],
662+
"variants": {"debug": {}, "release": {}},
663+
}
664+
}
665+
}
666+
result = parse_config(config, "/base/dir")
667+
668+
for r in result:
669+
self.assertEqual(r.visibility, frozenset({Tier.PUBLIC, Tier.PRIVATE}))
670+
671+
def test_unknown_visibility_rejected(self):
672+
"""An unknown tier name in visibility raises"""
673+
config = {"platforms": {"TestView": {"visibility": ["internal"]}}}
674+
with self.assertRaises(ValueError):
675+
parse_config(config, "/base/dir")
676+
628677
# =========================================================================
629678
# Variant naming
630679
# =========================================================================

‎scripts/cxx-api/tests/test_tiers.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,11 @@ def test_build_header_graph_ignores_self_include(self):
220220
# Skipped headers
221221
# =========================================================================
222222

223-
def skipped(self):
224-
return {os.path.relpath(p, self.root) for p in skipped_headers(self.graph())}
223+
def skipped(self, *included_tiers):
224+
args = (frozenset(included_tiers),) if included_tiers else ()
225+
return {
226+
os.path.relpath(p, self.root) for p in skipped_headers(self.graph(), *args)
227+
}
225228

226229
def test_skips_unreached_private_and_frameworks_headers(self):
227230
self.write("Public.h", PUBLIC)
@@ -243,3 +246,21 @@ def test_keeps_headers_reached_from_public(self):
243246
def test_never_skips_unclassified_headers(self):
244247
self.write("Unguarded.h")
245248
self.assertEqual(self.skipped(), set())
249+
250+
def test_keeps_included_tiers(self):
251+
self.write("Public.h", PUBLIC)
252+
self.write("Frameworks.h", FRAMEWORKS)
253+
self.write("Private.h", PRIVATE)
254+
255+
self.assertEqual(self.skipped(Tier.PUBLIC, Tier.FRAMEWORKS), {"Private.h"})
256+
self.assertEqual(
257+
self.skipped(Tier.PUBLIC, Tier.FRAMEWORKS, Tier.PRIVATE), set()
258+
)
259+
260+
def test_keeps_headers_reached_from_included_tiers(self):
261+
self.write("Public.h", PUBLIC)
262+
self.write("Frameworks.h", FRAMEWORKS + '#include "Private.h"\n')
263+
self.write("Private.h", PRIVATE)
264+
self.write("Other.h", PRIVATE)
265+
266+
self.assertEqual(self.skipped(Tier.PUBLIC, Tier.FRAMEWORKS), {"Other.h"})

0 commit comments

Comments
 (0)