|
| 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