Files
MobileGL/docs/Disaggregated/notes/recovered/wf3/scout-include-gate.md
T
swung0x48 aba329cc53 [Docs] (Disaggregated): P6's plan and contract draft; the working notes move from a local directory into the repository
Two things land together: the plan and contract draft P6 needs before it starts, and the
working notes that until now existed on exactly one machine.

1. P6's plan and contract draft (neither started; not one line of code)

     docs/Disaggregated/P6-SPAWN-PLAN.md      the package plan
     docs/Disaggregated/P6-CONTRACT-DRAFT.md  the contract draft (moves to
                                              MG_Remote/CONTRACT-P6.md when c6 lands)

   The three findings that mattered, all written into the documents:

   a. P6 is SMALLER than its roadmap cell suggests. The transport primitives are already in
      the tree and already tested: SocketDoorbell including hangup detection and the two
      lost-wakeup fences (Doorbell.cpp:211-360, four cases in FdPassingTest), SCM_RIGHTS fd
      passing, ShmSegment::Adopt, and the SurfaceOp / WindowKind schema already defined in
      protocol.fbs. inproc uses a real second shm mapping rather than an alias, and
      SessionRings.h:22-28 says that was done so today is not the first time it runs. P6 is
      assembly plus a control plane plus a process, not "write a socket transport".

   b. The evidence for "P6 is only a transport swap" has EXPIRED. It was bought by P5c, whose
      audit found 59 direct memory accesses in a tree that already looked clean. P5e changed
      the tree again. So the first package, a6, is a read-only audit that writes no code, and
      every contract row that depends on it is marked PENDING a6 rather than decided in
      advance.

   c. P6 does not get a game running on the phone. ANativeWindow* is meaningless in the child,
      and protocol.fbs:186-196's own comment says Android transfers the window out of band -
      that band is P12. P6 lands pbuffer / surfaceless / offscreen, which is what HeadlessGL,
      the split-equivalent lane and trace replay need, and which is exactly what P6's own exit
      gate measures. A real window reaching the server is a named refusal. This gets its own
      section because "P6 is done" would otherwise read as "the game runs now".

   The contract adds rule G: a role may not name a process-local handle. It extends P5c's
   rule E (a role may not name the other role's memory) from memory to identity - EGLDisplay /
   EGLSurface / EGLContext / ANativeWindow* / fd number / pid / mapped address either cross as
   a token this contract defines or they do not cross, and a descriptor crosses only through
   SCM_RIGHTS, where the kernel renumbers it.

   What P5e left for P6 gets its own section. The latch semantics are already designed
   (ARCHITECTURE.md:504) and are copied unchanged. What is new is that after run-ahead the
   client no longer waits while the peer process can DIE: the client holds records that were
   published and will never be applied, and Android's freezer suspends the child without
   closing anything. So slow and dead must be distinguishable WITHOUT a timeout threshold.
   That is P6's only new design; everything else is carriage. It shares a predicate with E1's
   outstanding re-specification (ID-122), and the two should be designed together.

   Two gates carried over from P5e's lessons: G2 requires integration-spawn's test-name set to
   match integration-split's name for name, because a differing set means the lane is quietly
   running less; and the arm-proof gate requires every entry to record the child pid and
   transport=spawn in its OWN private log - ID-124 established that this kind of false green
   really happens, and a spawn lane that actually ran monolith would pass every other gate.
   S2's shape must be settled before it is written, and the document says why: E1 is the
   cautionary case.

2. The working notes move into the repository (docs/Disaggregated/notes/, 415 .md files)

   ROADMAP / CURRENT_STAGE_PROGRESS / MEASUREMENTS cite ruling numbers, audit line numbers and
   the provenance of gate figures from ~/w7/notes/, a directory that exists on one machine and
   is under no version control. A record cited hundreds of times that lives only locally is not
   a record, so it moves in whole, with its directory structure intact.

   Only .md moves. Scripts, run logs, census and gate result JSON, patches, APKs and nm dumps
   stay where they are: they are evidence DATA rather than documents - large, regenerated per
   run, and mostly carrying absolute paths and device serials that mean something only on the
   machine that produced them. The mapping and this split are written in
   docs/Disaggregated/notes/README.md.

   Two exceptions are recorded there too: P6's BRIEF has been promoted to a first-class
   document and keeps no copy under notes/, so the two cannot drift apart; and recovered/ holds
   material salvaged from failed workflow runs, which may duplicate the like-named phase
   directories and is kept because a few of those were the only complete version at the time.

   CI's citation lint takes docs/Disaggregated/*.md only (non-recursive), and notes/ is
   deliberately outside it: those notes' file:line citations were written against their own
   code heads, and resolving them against today's HEAD would fail wholesale - which is not an
   error, it is their correct state as a historical record. To check one, use the revision it
   declares (--rev).

3. Inconsistencies fixed in passing

   - README.md's status line still said P5e was in progress while the stage table already said
     closed.
   - CURRENT_STAGE_PROGRESS's next-step item 1 still listed the exit gate as pending, though
     (a) and (b) were run in the previous commit; rewritten to closed plus the one item that
     actually remains (E1).
   - ROADMAP's P6 row still had the pre-phase plan in three of its cells; it now carries the
     package order, the scope boundary and the two new gates.

Checked with check_doc_citations.py: 125 citations across docs/Disaggregated/*.md, 0 problems.
2026-09-18 23:31:39 -04:00

878 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P0.5 CI include-closure gate — scout report
Tree scouted: `C:/Users/geekerwan/AndroidStudioProjects/FoldCraftLauncher/MobileGL-disagg`, branch `feat/disaggregated`.
Every path below is repo-relative to that root unless written absolute. Every line number was opened.
Live measurements in section 5 were run in WSL against `/home/swung/w7/pipe` (the same branch, `a4baa9d4`, 12 commits ahead of the Windows worktree) because it has configured build trees. All of the headers involved are byte-identical in the two worktrees for the purposes of these measurements; re-run the commands after rebasing if you need exact numbers for a specific commit.
---
## 0. Two naming corrections before anything else
The task brief names two headers as if they exist. **Neither exists in the tree today** — P0.5 *creates* them:
- `MobileGL/MG_Pipe/MGPipeValueTypes.h` — does not exist. `find MobileGL -iname "*ValueType*"` returns nothing. The real, existing header is `MobileGL/MG_Pipe/MGPipeTypes.h`.
- `MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h` — does not exist. `ls MobileGL/MG_State/GLState/ProgramState/` shows: `ProgramLinkTask.{cpp,h}`, `ProgramObject.{cpp,h}`, `ProgramPipelineObject.h`, `ProgramSpirvTask.{cpp,h}`, `ProgramState.{cpp,h}`, `ProgramTranslationCache.{cpp,h}`, `ShaderCompileAdoptionMap.{cpp,h}`, `ShaderCompileTask.{cpp,h}`, `ShaderObject.{cpp,h}`, `ShaderPreprocessCache.{cpp,h}`, `ShaderSourceKey.h`, `ShaderStage.h`. The types to extract live in `ProgramObject.h`.
So the gate is written **against headers that P0.5 introduces**. It must therefore be able to run in a "not yet extracted" state without crashing the CI for a missing file — decide explicitly: either land the gate in the same commit as the headers, or give the script a `--allow-missing` that prints SKIP. Recommendation below: land in the same commit, no skip mode (a gate that can silently skip is the "all-skip run indistinguishable from a pass" failure the integration lane comment at `.github/workflows/test.yml:222-225` was written about).
---
## 1. Existing gates in `.github/workflows/test.yml` (861 lines total)
Job list with line numbers (`grep -n "^ [a-zA-Z0-9_-]*:"`):
| line | job | runs-on | needs |
|---|---|---|---|
| 12 | `build-linux` | ubuntu-latest | — |
| 149 | `test` | ubuntu-latest | build-linux |
| 205 | `integration` | ubuntu-latest | build-linux |
| 304 | `flatc-check` | ubuntu-latest | — |
| 325 | `benchmark` | ubuntu-latest | build-linux |
| 381 | `build-retrace` | ubuntu-latest | — |
| 526 | `trace-cases` | ubuntu-latest | — |
| 546 | `trace-fixtures` | ubuntu-latest | — |
| 611 | `retrace` | ubuntu-latest | — |
| 717 | `retrace-summary` | ubuntu-latest | — |
| 758 | `remove-artifact-clutter` | ubuntu-latest | — |
| 809 | `pipe-gates` | ubuntu-latest | — (deliberately) |
Triggers, `test.yml:2-9`: `push` on `dev`, `Feat/Backend-Direct-GLES`, `Feat/Backend-Direct-Vulkan`, plus `workflow_dispatch`. **`feat/disaggregated` is not a trigger branch** — a new gate added here will only run on `workflow_dispatch` or after the branch merges, unless you add the branch to the `branches:` list. Flag this to the implementer; it is the single easiest way for a "green gate" to be green because it never ran.
### 1.1 `pipe-gates` (test.yml:809-861) — the job to extend
```yaml
809 pipe-gates:
810 name: MGPipe generators and hygiene gates
811 runs-on: ubuntu-latest
812 # Deliberately independent of build-linux: these are source-level gates, they take
813 # seconds, and a broken build must not hide a drifted interface.
814
815 steps:
816 - name: Checkout repo
817 uses: actions/checkout@v6
```
Note `actions/checkout@v6` at 816-817 with **no `with: submodules:`** — this job has an empty `3rdparty/` and an empty `include/ska/`. That is load-bearing for the design (section 5.4).
Steps:
- **819-827 "Regenerate the MGPipe interface (G1-G7)"**
```
python3 scripts/gen_pipe.py
git diff --exit-code -- MobileGL/MG_Pipe/generated
```
Failure surfaces as a non-zero exit from `git diff --exit-code` (a diff printed to the log). Note it runs the *writing* mode and diffs, not `--check`.
- **829-846 "No stdio instrumentation in MG_Backend or MG_State"** — the stdio grep gate:
```bash
if grep -rnE 'fprintf[[:space:]]*\((stderr|stdout)|(^|[^[:alnum:]_>.])printf[[:space:]]*\(|(^|[^[:alnum:]_>.:])puts[[:space:]]*\(|std::(cout|cerr)' \
MobileGL/MG_Backend MobileGL/MG_State; then
echo "::error::stdio instrumentation found; use MGLOG_D (compiled out in INFO builds)"
exit 1
fi
echo "no fprintf(stderr/stdout / printf( / puts( / std::cout|cerr under MobileGL/MG_Backend or MobileGL/MG_State"
```
Failure surfaces as a GitHub `::error::` annotation plus `exit 1`. **This is the house style for a new grep/scan gate**: annotate with `::error::`, then exit 1, and print an affirmative one-liner on success. Zero exceptions by policy (comment at 831-835: "this gate starts with no exceptions, and any addition to it needs a reason in the pull request rather than a quiet whitelist entry"). Copy that policy for the closure allow-list.
- **848 "MGPipe dirty-surface report"**: `python3 scripts/gen_pipe_dirty_surface.py --summary` — informational only (comment 846-847: "It becomes a gate in P1").
- **850-861 "Documentation citation lint"**:
```bash
shopt -s nullglob
documents=(docs/Disaggregated/*.md)
if [ ${#documents[@]} -eq 0 ]; then echo "no disaggregation documents to check"; exit 0; fi
python3 scripts/check_doc_citations.py "${documents[@]}" || true
```
Warning-only via `|| true` (comment 849-851: becomes `--strict` when the documents settle). This is the last step of the file (file ends at 861).
### 1.2 `flatc-check` (test.yml:298-323) — the model for a compiler-needing, submodule-selective job
Header comment 298-303 explains why the generated header is committed and flatc kept out of the build graph. Steps:
- 306-307 `Checkout repo` / `actions/checkout@v6` (no submodules)
- 309-310 `Get CMake` / `lukka/get-cmake@v4.3.3`
- 312-315 **`Check out the FlatBuffers submodule only`**: `git submodule update --init 3rdparty/flatbuffers`, with the comment "Just this one: the schema check has nothing to do with glslang, SPIRV-Cross or the trace fixtures."
- 317-318 `Regenerate protocol_generated.h`: `python3 scripts/gen_protocol.py --build-dir "${{ runner.temp }}/flatc-build"`
- 320-321 `Fail if the committed header is stale`: `git diff --exit-code -- MobileGL/MG_Remote/Protocol/generated/protocol_generated.h`
**Use this job's selective-submodule pattern verbatim** for the include-closure gate (section 6): `git submodule update --init include/ska 3rdparty/xxHash 3rdparty/Vulkan-Headers` is the whole dependency set (proved in 5.4).
### 1.3 `build-linux` (test.yml:12-148) — toolchain and configure facts
- 59-60 installs the toolchain: `sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build`. So the project's CI compiler is **clang-20**, installed explicitly, not the image default.
- 52-54: `python update_glslang_sources.py` in `3rdparty/glslang` — a prerequisite for a *full* configure, not for the closure probe.
- 71-91 `Configure CMake`:
```
cmake -S . -B "${BUILD_DIR}" -G Ninja \
-DCMAKE_C_COMPILER=clang-20 -DCMAKE_CXX_COMPILER=clang++-20 \
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \
-DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \
-DMOBILEGL_BUILD_TEST=ON -DMOBILEGL_BUILD_BENCHMARK=ON \
-DMOBILEGL_BUILD_INTEGRATION_TEST=ON \
-DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/lvp_icd.json \
-DMOBILEGL_BUILD_TRACE_REPLAY=OFF \
-DBENCHMARK_DOWNLOAD_DEPENDENCIES=ON -DBENCHMARK_ENABLE_TESTING=OFF \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
```
**`MOBILEGL_BUILD_DISAGGREGATED` is not passed** — CI's `build-linux` builds the OFF (shipping) configuration only. `BUILD_DIR=build-linux` (env, line 17).
- 94 `Build`: `cmake --build "${BUILD_DIR}" --parallel "$(nproc)"`
- 122-138 packages `ci-artifacts/mobilegl-linux-runtime.tgz` containing `CTestTestfile.cmake`, `MobileGL/MG_Test`, `MobileGL/MG_Benchmark`, `MobileGL/MG_IntegrationTest` and every `*.so` found — **note it excludes `*.a` and `*.o`, and `libMobileGL.so` IS in the `SHARED_LIBS` glob (127)**. That matters for the nm report: `libMobileGL.so` already leaves `build-linux` as an artifact (140-146, `actions/upload-artifact@v7`, name `mobilegl-linux-runtime`, `if-no-files-found: error`).
- 139-146 upload. 96-98 `ccache --show-stats` `if: always()`.
- 100-120: rolling ccache cache entry, only written on the default branch.
`test` job (149-203) downloads that artifact (176-180), unpacks (182-183), rewrites CTest command paths with an inline python heredoc (185-196) and runs `ctest --output-on-failure -L unit --no-tests=error` (198-203). **`--no-tests=error` is the house guard against a vacuous pass** — any new ctest must carry the right label (`unit`) or it will never run in CI.
### 1.4 `scripts/gen_pipe.py --check`
- Docstring 9-30; usage lines 25-26:
```
python3 scripts/gen_pipe.py # write the generated files, print the summary
python3 scripts/gen_pipe.py --check # fail if regenerating would change anything
```
- `REPO_ROOT` derived from `__file__` at line 37: `os.path.dirname(os.path.dirname(os.path.abspath(__file__)))` — **copy this idiom**, it makes the script cwd-independent.
- `write(path, text, check, changed)` at 618-626: compares against the existing file, appends `os.path.relpath(path, REPO_ROOT)` to `changed`, writes only when `not check`.
- `main()` 628-671: `argparse.ArgumentParser(description=__doc__)`, `--check` as `action="store_true"` (630-632). On drift with `--check` (663-666): `print("gen_pipe: OUT OF DATE: %s" % ", ".join(changed), file=sys.stderr); return 1`. Otherwise 669 `print("gen_pipe: generated files are up to date")`, `return 0`. Entry point 673-674 `sys.exit(main())`.
- Prints a summary block at 653-660 with a `gen_pipe:` prefix on every line. **House style: `<toolname>: <message>` prefix on every stdout line.**
- Hard errors use `sys.exit("message")` (lines 135, 153, 158, 178, 182, 193, 202, 208, 219, 519).
### 1.5 `scripts/check_doc_citations.py` (117 lines)
- Docstring 10-26 states the motivation and the two usages; last line 26: "Exits non-zero only with `--strict`, so it can be wired into CI as a warning first."
- `REPO_ROOT` at 33, same idiom.
- `CITATION_RE` 37-40 — a path-with-extension followed by `:line[-line]`.
- `git(args, rev_root=REPO_ROOT)` 43-45 shells `git -C <root> …` with `check=False`.
- `class Tree` 48-73: builds `Paths`, `ByBasename`, lazily counts lines from `git show rev:path`.
- `main()` 76-114: `documents nargs="+"`, `--rev` (default `HEAD`), `--strict`. Summary line 108-109 `check_doc_citations: %d citations in %d document(s) against %s, %d problem(s)`; problems printed indented; `return 1` only when `problems and args.strict` (112-113).
**Two reusable patterns for the new script**: (a) a `--strict`-style escalation switch so the gate can land as a warning and become blocking in a later commit; (b) a machine-countable summary line printed unconditionally.
---
## 2. Build configuration — CMake facts an implementer needs
Root `CMakeLists.txt` is 38,244 bytes.
### 2.1 Options
| line | option | default |
|---|---|---|
| 5 | `MOBILEGL_BUILD_TEST` | ON (forced OFF for `ANDROID` at line 29) |
| 6 | `MOBILEGL_BUILD_BENCHMARK` | ON (forced OFF for `ANDROID` at 30) |
| 11 | `MOBILEGL_BUILD_INTEGRATION_TEST` | OFF |
| 12 | `MOBILEGL_FORCE_RELEASE_OPT` | ON |
| 13 | `MOBILEGL_ENABLE_TRACY` | OFF |
| 14 | `MOBILEGL_BUILD_TRACE_REPLAY` | OFF |
| 15 | `MOBILEGL_TRACE_ANGLE_VARIANTS` | OFF |
| 16 | `MOBILEGL_IOS` | OFF |
| 23 | `MOBILEGL_BUILD_DISAGGREGATED` | OFF |
| 24 | `MOBILEGL_BUILD_SERVER_SPIKE` | OFF |
| 108 | `MOBILEGL_ENABLE_LTO` | OFF |
`CMakeLists.txt:17-22` is the comment that names the nm equality this whole family of gates protects:
> "OFF is the shipping default and OFF must stay byte-comparable to a tree without MG_Remote at all: nothing under `MobileGL/MG_Remote/` is compiled, no include path is added, and no library is linked, so `nm --defined-only libMobileGL.so | grep -i MG_Remote` is empty. That emptiness is one of the two byte-level equalities the plan's validation gates keep (section 10.3)."
Also: `MOBILEGL_LOG_ACTIVE_LEVEL` is a cache STRING, default `"MOBILEGL_LOG_LEVEL_INFO"` (line 25).
### 2.2 Compiler / language
- 155 `enable_language(CXX)`; 157 `set(CMAKE_CXX_STANDARD 23)`; 158 `CMAKE_CXX_STANDARD_REQUIRED ON`.
- **160 `set(CMAKE_EXPORT_COMPILE_COMMANDS ON)`** — unconditional. Every configured build tree has a `compile_commands.json`. Confirmed present at `~/w7/pipe/{build,build-linux,build-linux-split,build-retrace,build-android}/compile_commands.json`.
- 162 `CMAKE_POSITION_INDEPENDENT_CODE ON`. 165 MSVC `/EHsc`. 149-151 MSVC `/Zc:preprocessor` + `/Zc:__cplusplus`.
- 137-146: with `MOBILEGL_ENABLE_LTO`, `-O3 -ffunction-sections -fdata-sections` + `-Wl,--gc-sections`; off by default, so the default Release build does **not** use `--gc-sections`. Relevant to the nm/size report: section-splitting is off by default, so per-symbol sizes from `nm -S` are comparable between builds.
- 168-183: `MGGitHash.h` is `configure_file`d into `${CMAKE_BINARY_DIR}/generated` and 185 `include_directories(${CMAKE_BINARY_DIR}/generated)` — a *build-dir* include path. (Verified in section 5.4 that the closure probe does not need it.)
- 578-583 / 596-598 visibility presets: `hidden` in non-Debug, `default` in Debug. This directly changes the `nm --defined-only` output; **the nm report must compare two builds of the same `CMAKE_BUILD_TYPE`**.
### 2.3 Include directories — `MOBILEGL_INCLUDE_DIR` (`CMakeLists.txt:530-544`)
```cmake
set(MOBILEGL_INCLUDE_DIR
${CMAKE_SOURCE_DIR}/include
${CMAKE_SOURCE_DIR}/MobileGL
# The MGPipe boundary headers. …
${CMAKE_SOURCE_DIR}/MobileGL/MG_Pipe # line 535
${spirv-tools_SOURCE_DIR}
${spirv-tools_SOURCE_DIR}/include
${spirv-tools_BINARY_DIR}
${SPIRV-Headers_SOURCE_DIR}/include
${CMAKE_SOURCE_DIR}/3rdparty/asio/include
)
```
and, only when disaggregated, `CMakeLists.txt:546-551` appends `${CMAKE_SOURCE_DIR}/3rdparty/flatbuffers/include`.
Applied to both targets: `target_include_directories(${CMAKE_PROJECT_NAME} PUBLIC ${MOBILEGL_INCLUDE_DIR})` at 579-581 (shared `MobileGL`) and 638-640 (static `MobileGL_s`, inside `if(NOT ANDROID)` at 619).
Note that `MG_Remote` gets **no include path of its own** — its headers are reached as `<MG_Remote/...>` through `${CMAKE_SOURCE_DIR}/MobileGL`, and internally by relative `"../Protocol/mg_protocol_base.h"` (`MobileGL/MG_Remote/Transport/ITransport.h:33`, `Framing.h:32`, `Ring.h:47`).
Sources: `MG_Remote` `.cpp` files are appended to `SOURCE_FILES` at `CMakeLists.txt:454-471` under `if (MOBILEGL_BUILD_DISAGGREGATED)`; the flatbuffers-submodule-missing fallback that shadows the option OFF for one configure is at 440-451; `-DMOBILEGL_BUILD_DISAGGREGATED=1` is appended to `MOBILEGL_COMPILE_DEF` at 523-525.
`MOBILEGL_COMPILE_DEF` (`CMakeLists.txt:512-521`): `-DVMA_STATIC_VULKAN_FUNCTIONS=0 -DVMA_DYNAMIC_VULKAN_FUNCTIONS=1 -DVMA_VULKAN_VERSION=1001000 -DASIO_STANDALONE -DASIO_NO_DEPRECATED`, plus `MOBILEGL_LOG_ACTIVE_LEVEL=${MOBILEGL_LOG_ACTIVE_LEVEL}` at 585-589 / 645-649 and `MOBILEGL_TRACE_ANGLE_VARIANTS` via a generator expression at 588.
### 2.4 Where the test targets are declared
- Root gate: `CMakeLists.txt:732-763`, inside `if (NOT ANDROID)`; 741 `enable_testing()` at top-level scope (comment 732-740 explains that this is what puts a `CTestTestfile.cmake` at the build root so `ctest -L unit` works from there); 744-746 `if (MOBILEGL_BUILD_TEST) add_subdirectory(MobileGL/MG_Test) endif()`; 750-752 integration; 754-756 benchmark; 758-760 trace_replay.
- `MobileGL/MG_Test/CMakeLists.txt` (103 lines):
- 1-2 `cmake_minimum_required(VERSION 3.24)` / `project(MobileGLTest)`
- 6-7 C++23
- **9 `set(MGL_ROOT ${CMAKE_CURRENT_LIST_DIR}/../..)`** — the variable every sub-CMakeLists uses for include paths.
- 15-24 `FetchContent` googletest v1.17.0 (`gtest_force_shared_crt ON`)
- 26 `enable_testing()`
- 32-47 the `SanityTest` target, include dirs `${MGL_ROOT}/include` and `${MGL_ROOT}/MobileGL`
- **60-62 `set(LINK_LIBRARIES MobileGL_s)`** — the variable every sub-CMakeLists links
- 64-65 `include(GoogleTest)` + `gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)`
- 67-74 MSVC `/WHOLEARCHIVE:MobileGL_s`
- 76-99 the `add_subdirectory` list (`BackendLoader Buffer State EGLState Framebuffer Texture VertexArray Program Query Pipeline Pipe ShaderTranspiler Util SelfTest Backend/DirectGLES`, `Backend/DirectVulkan` under `ENABLE_INTEGRATION_TESTS`)
- 100-103 `if (MOBILEGL_BUILD_DISAGGREGATED) add_subdirectory(Wire) endif()` — the disaggregated-only suite (this is the `MG_Test/CMakeLists.txt:93-95` the docs cite; the real lines are 100-103 in this worktree, a citation the doc lint would flag once ARCHITECTURE.md is re-pinned).
- **The model target — `MobileGL/MG_Test/Pipe/CMakeLists.txt` (28 lines)**:
```cmake
1 cmake_minimum_required(VERSION 3.14)
3 add_executable(PipeCatalogueTest PipeCatalogueTest.cpp)
8 target_include_directories(PipeCatalogueTest PRIVATE
9 ${MGL_ROOT}/include
10 ${MGL_ROOT}/MobileGL
11 ${MGL_ROOT}/MobileGL/MG_Pipe
12 ${MGL_ROOT}/3rdparty/xxHash
13 ${MGL_ROOT}/3rdparty/Vulkan-Headers/include
14 ${MGL_ROOT}/3rdparty/SPIRV-Reflect
15 )
17 target_link_libraries(PipeCatalogueTest PRIVATE GTest::gtest_main ${LINK_LIBRARIES})
23 if (MSVC) target_compile_options(PipeCatalogueTest PRIVATE /Zc:preprocessor) endif()
27 include(GoogleTest)
28 gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
```
Note lines 9-14: **this target already spells out, by hand, almost exactly the minimal include set the closure probe needs** (section 5.4). It is the precedent for hand-listing include roots outside `MOBILEGL_INCLUDE_DIR`.
- `MobileGL/MG_Test/Wire/CMakeLists.txt` (36 lines) is the loop form: a `MOBILEGL_WIRE_TESTS` list (7-12), `FdPassingTest` appended only `if (NOT WIN32)` (14-17), and a `foreach` (21-36) that adds the executable, the include dirs (`include`, `MobileGL`, `3rdparty/flatbuffers/include`) and `gtest_discover_tests(... LABELS unit)`.
- Non-gtest `add_test` precedent (for a script-driven ctest): `tools/trace_replay/CMakeLists.txt:350-372` — `add_test(NAME MobileGLTraceReplay.${CASE_NAME}.${BACKEND} COMMAND "${CMAKE_COMMAND}" -D... -P .../run_trace_case.cmake)`, then `set_tests_properties(... PROPERTIES ENVIRONMENT "…")` at 373-380. `find_package(Python3 REQUIRED)` + `${Python3_EXECUTABLE}` appear at `tools/trace_replay/CMakeLists.txt:15` and `:390` and at `android-plugin/app/src/trace/cpp/CMakeLists.txt:9,51`. **There is no `find_package(Python3)` in the root `CMakeLists.txt` or under `MG_Test`** — a python-driven ctest under `MG_Test` must add its own.
- Benchmarks show the plain form: `MobileGL/MG_Benchmark/CMakeLists.txt:40 add_test(NAME SanityBench COMMAND SanityBench …)`.
### 2.5 Does a `compile_commands.json` / `clang -H` probe fit?
Yes, and better than expected — see section 5. Summary of the fit:
- `CMAKE_EXPORT_COMPILE_COMMANDS ON` is unconditional (`CMakeLists.txt:160`), so a local/ctest run can always read the *real* flags out of the build tree it lives in. 816 entries in `~/w7/pipe/build-linux-split/compile_commands.json`, 67 of them under `MG_State`.
- But a full configure is **not required**: a five-flag hand-rolled command reproduces the exact same 666-line closure (5.4). So the gate can also run in a source-only job in ~0.15 s.
- Both `clang++ -H` and `g++ -H` work and agree on the in-tree part of the closure (5.5).
---
## 3. What the docs require of the gate — verbatim quotes
### 3.1 `docs/Disaggregated/ROADMAP.md:16` — the P0.5 row (full row, `|`-separated)
> `| **P0.5** 值头与制品头抽取 | 6–9 | `MG_Pipe/MGPipeValueTypes.h`(`RenderStateParameters`、`SamplerParameters`、`PixelStoreParameters`、`VertexAttribute`… 不 include `MG_State/GLState`);`MG_State/GLState/ProgramState/ProgramArtifacts.h`(五个反射类型,不 include `ShaderObject.h`/`SpvcSession.h`,更新 7 个 includer);`Visit()` 归档 + `sizeof` 绊线;CI `-H` include 闭包断言 | 全套测试逐名不变(纯搬移);两条闭包断言绿且人为加回一个 `MG_State` include 能变红;`nm`/`.text` 变化可逐符号归因 | P0;**P1 与 P7 的硬前置** |`
Decomposed acceptance gate (column 4):
1. **全套测试逐名不变(纯搬移)** — the whole suite unchanged *test-name by test-name*; the extraction is a pure move.
2. **两条闭包断言绿** — two closure assertions green.
3. **人为加回一个 `MG_State` include 能变红** — deliberately adding an `MG_State` include back must turn it red.
4. **`nm`/`.text` 变化可逐符号归因** — nm/.text changes must be attributable symbol by symbol.
### 3.2 `docs/Disaggregated/ROADMAP.md:7` — the per-commit discipline
> 默认 ALL target 必须完整构建;禁止提交热路径插桩(CI grep 门);**每个门必须能因它存在的理由变红**;Windows 机器不是正确性门;…
"**Every gate must be able to turn red for the reason it exists**" is the sentence that makes the negative control mandatory and not optional.
### 3.3 `docs/Disaggregated/ARCHITECTURE.md:501` — §13.2 item 1, purity gate A
> 1. **接口纯度三道门**(只跑非 verify 构建):**A 门 include 图**——disaggregated 配置编译 `MG_Backend` 时把 `MG_State/GLState` 从 include 搜索路径移除(`nm --undefined-only` 对"只 include 不调用"是瞎的,而 `RenderState.h → FramebufferObject.h → TextureObject.h` 正是这种耦合),依赖 P0.5;**B 门符号**——`nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang'` 为空;**C 门未声明**——`grep -c 'pGLContext' MG_Backend/` == 0。…
Two things to carry into the design: (a) gate A's ultimate form is *removing `MG_State/GLState` from the include search path* when compiling `MG_Backend` — the `-H` closure assertion is the P0.5 stepping stone toward that; (b) the doc names the exact chain `RenderState.h → FramebufferObject.h → TextureObject.h` as the motivating coupling, and section 5.3 below **confirms that chain exists today, byte for byte**.
`ARCHITECTURE.md:499` (the sentence that opens §13.2):
> "改前改后 `nm --defined-only` 与 `.text` size 完全相等"的门在本方案里按构造死亡(不存在能让旧字节回来的配置);替换是:
`ARCHITECTURE.md:507` (the two surviving byte-level equalities, and the drift-report requirement):
> 两条幸存的字节级等式:`MOBILEGL_BUILD_DISAGGREGATED=OFF` 时 `nm --defined-only libMobileGL.so | grep MG_Remote` 为空且链接行不增加库;`nm -D libMobileGL.so | grep mobilegl_server_main` 在 RelWithDebInfo 里命中。符号与 `.text` 漂移每阶段作为信息性指标发布。
("symbol and `.text` drift is published as an informational metric each phase" — that is the home for the nm/size report of section 8.)
### 3.4 `docs/Disaggregated/ARCHITECTURE.md:568` — §16 构建布局, the CI bullet
> - CI(`.github/workflows/test.yml:809` `pipe-gates`,P0 已落地):`gen_pipe.py` 重生成 + diff;`MG_Backend`/`MG_State` 下禁止 stdio 插桩的 grep 门;`gen_pipe_dirty_surface.py --summary`(信息性,P1 成门);`check_doc_citations.py`(警告级,文档定稿后 `--strict`)。独立 job `flatc-check`。后续:**`include-graph-check`(P0.5)**、`monolith-symbol-report`。
**The gate's name is already chosen by the docs: `include-graph-check`, and it is described as a *后续* (follow-on) item alongside `monolith-symbol-report`.** The natural reading — "独立 job `flatc-check`. 后续:`include-graph-check`(P0.5)、`monolith-symbol-report`" — is that these two are also standalone jobs. Section 6 proposes exactly that, and separately proposes reusing `monolith-symbol-report` as the name of the nm/.text job.
§16 also fixes the tree layout (`ARCHITECTURE.md:550-561`), naming `MobileGL/MG_Pipe/` as "永远进构建(monolith 的架构,不在任何 option 之后) [P0]".
Other §16 lines the implementer needs:
- `ARCHITECTURE.md:563` — the CMake option paragraph, citing `CMakeLists.txt:23`, `454-469`, `440-451`, and `MobileGL/MG_Test/CMakeLists.txt:93-95`.
- `ARCHITECTURE.md:564` — `MOBILEGL_BUILD_DISAGGREGATED_INPROC` (does not exist yet) and the sentence "server 角色不再读 `pGLContext`(三道纯度门就是这个断言)".
- `ARCHITECTURE.md:567` — the ctest wiring traps: "ctest `ENVIRONMENT` 是替换而非追加、`;` 必须转义、property 覆盖 job env". Confirmed independently by the comment at `.github/workflows/test.yml:222-227`.
### 3.5 `docs/Disaggregated/ARCHITECTURE.md:260` — §7, the exact extraction contract
> - **P0.5 硬前置**:反射类型今天声明在 `ProgramObject.h` 里,而它 include `ShaderObject.h`(→ glslang)与 `SpvcSession.h`(→ spirv_reflect)。P0.5 把 `TypeFacts`、`ResourceReflection`、`XfbVarying`、`LinkArtifacts`、`SpirvArtifacts` 抽到 `MG_State/GLState/ProgramState/ProgramArtifacts.h`(只 include `<Includes.h>` 与容器),更新 7 个 includer,加 CI `-H` 闭包断言。同批抽取 `MG_Pipe/MGPipeValueTypes.h`(`MAX_DRAW_BUFFERS`、`PerBufferBlendState`、`StencilFaceState`、`PixelStoreParameters`、`RenderStateParameters`、`SamplerParameters`、`BorderColorForm`、`VertexAttribute`、`VertexBufferBindingPoint`),它不 include `MG_State/GLState` 任何东西;`MGPipeTypes.h` 今天为此临时 include 了 `BackendObject.h` 与 `RenderState.h`(文件头注明为 P0.5 债务)。没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。
**This is the authoritative spec of the two closure assertions.** Note carefully:
- `ProgramArtifacts.h` is specified as "**只 include `<Includes.h>` 与容器**" — it *keeps* the umbrella header. Since `MobileGL/Includes.h:59` includes `<spirv_cross/spirv_cross_c.h>` and `:79-83` include five glslang headers, **the ProgramArtifacts assertion CANNOT be "no glslang / no spirv-cross in the closure"**. It must be "no `ShaderObject.h`, no `SpvcSession.h`" (and, in practice, none of the chain those two drag in). See section 5.6.
- `MGPipeValueTypes.h` is specified as "不 include `MG_State/GLState` 任何东西" — a *directory-prefix* assertion. `MGPipeTypes.h`'s own P0.5 debt note names both offending includes.
The file-level debt note is in the tree at `MobileGL/MG_Pipe/MGPipeTypes.h:25-33`:
> // P0.5 DEBT, recorded here so it is impossible to miss: two payloads reach into headers
> // this directory is eventually forbidden to see - MGPCaps embeds MG_Backend's
> // DynamicBackendParameters, and ResidualValueBlock embeds MG_State's RenderStateParameters
> // and PixelStoreParameters. … P0.5
> // extracts MGPipeValueTypes.h and both includes below go away; until then purity gate A
> // (section 10.3) cannot be armed for this header.
> `#include <MG_Backend/BackendObject.h>` ← line 32
> `#include <MG_State/GLState/RenderState/RenderState.h>` ← line 33
And `MGPipeTypes.h:35-40` has the `using` declarations that must follow the types when they move:
```cpp
namespace MobileGL::MG_Pipe {
using MG_Backend::DynamicBackendParameters;
// Both live directly in namespace MobileGL today; P0.5 moves them into
// MG_Pipe/MGPipeValueTypes.h.
using MobileGL::PixelStoreParameters;
using MobileGL::RenderStateParameters;
```
`ARCHITECTURE.md:259` gives the companion `Visit()`/`sizeof` tripwire requirement (`static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE)`) and enumerates the fields the archive must cover — out of scope for the include gate but same commit.
### 3.6 Precedent already in the tree — `MG_Remote/Transport` is umbrella-free by rule
`MobileGL/MG_Remote/Transport/WireLog.h:9-24` is the strongest existing statement of intent, and it names the mechanism this gate implements:
> // MG_Util/Debug/Log.h includes `<Includes.h>`, the GL frontend's umbrella
> // header - **661 headers, measured with `clang++ -H`**. That is fine inside a
> // .cpp … It is not fine in a header of this layer: ITransport.h states the rule
> // ("nothing about a byte pipe needs the GL frontend's umbrella header") … and
> // because the disaggregated build's include-graph purity gate (plan section
> // 10.3, gate A) asserts on `-H` output rather than on symbols. Framing.h was
> // the one header under Transport/ that broke the rule; it now calls this
> // instead, and the umbrella stays inside WireLog.cpp.
Their includes today (all in-tree-clean):
- `ITransport.h:33,35`: `"../Protocol/mg_protocol_base.h"`, `<cstdint>`
- `Framing.h:32,36,38-41`: `"../Protocol/mg_protocol_base.h"`, `"WireLog.h"`, `<cstddef> <cstdint> <cstring> <vector>`
- `Ring.h:47,49-51`: `"../Protocol/mg_protocol_base.h"`, `<atomic> <cstddef> <cstdint>`
**Recommendation: make this a third, free probe** (section 6.3). It is green today, it costs 30 ms, and it locks a rule that a single careless `#include <Includes.h>` in `Framing.h` has already broken once.
---
## 4. Ground truth about the headers to be extracted
### 4.1 Current include edges (all verified by opening the files)
```
MG_Pipe/MGPipe.h:10,12-15 <Includes.h>, "MGPipeCallbacks.h", "MGPipeHandles.h", "MGPipeHostSpan.h", "MGPipeTypes.h"
MG_Pipe/MGPipe.h:65,68,77,80,83,86,89,92 "PipeCalls.def" + the seven generated/*.inc
MG_Pipe/MGPipeCallbacks.h:10,12-13 <Includes.h>, "MGPipeHandles.h", "MGPipeTypes.h"
MG_Pipe/MGPipeHandles.h:10 <Includes.h> ← already MG_State-free
MG_Pipe/MGPipeHostSpan.h:10 <Includes.h> ← already MG_State-free
MG_Pipe/MGPipeTypes.h:10,12-13 <Includes.h>, "MGPipeHandles.h", "MGPipeHostSpan.h"
MG_Pipe/MGPipeTypes.h:32 <MG_Backend/BackendObject.h> ← P0.5 debt
MG_Pipe/MGPipeTypes.h:33 <MG_State/GLState/RenderState/RenderState.h> ← P0.5 debt
MG_State/GLState/RenderState/RenderState.h:10-12 <Includes.h>, <MG_Util/Math/VectorTypes.h>,
<MG_State/GLState/FramebufferState/FramebufferObject.h>
MG_State/GLState/FramebufferState/FramebufferObject.h:10-13
"MG_Util/Types.h", <Includes.h>,
<MG_State/GLState/TextureState/TextureObject.h>,
<MG_State/GLState/RenderbufferState/RenderbufferObject.h>
MG_Backend/BackendObject.h:10-11 <Includes.h>, "MG_State/GLState/TextureState/TextureEnum.h"
MG_State/GLState/ProgramState/ProgramObject.h:10,11,13,14
<Includes.h>, "ShaderObject.h", <MG_Util/Metrics/BufferMetrics.h>,
<MG_Util/ShaderTranspiler/SpvcSession.h>
```
`RenderState.h → FramebufferObject.h → TextureObject.h` — the coupling `ARCHITECTURE.md:501` names — is `RenderState.h:12` → `FramebufferObject.h:12`. Confirmed.
### 4.2 The types to move
Value types (target `MG_Pipe/MGPipeValueTypes.h`), all in `MobileGL/MG_State/GLState/RenderState/RenderState.h` (538 lines, `namespace MobileGL` at the outer level, closing at 538):
- `PixelStoreParameters` at **:191-200** (8 scalar members)
- `PerBufferBlendState` at **:202-210**
- `StencilFaceState` at **:212-220**
- `RenderStateParameters` at **:222-…**, with `static constexpr Uint MAX_VIEWPORTS = 16;` at **:227** and `Array<FloatVec4, MAX_VIEWPORTS> Viewports{}` at **:238**
- the state class's private data at **:527-537**: `Uint16 m_version`, `Uint16 m_pipelineStateVersion`, `RenderStateParameters m_parameters`, two `PixelStoreParameters`
`ARCHITECTURE.md:260` additionally names `MAX_DRAW_BUFFERS`, `SamplerParameters`, `BorderColorForm`, `VertexAttribute`, `VertexBufferBindingPoint` — those live in the sampler/vertex-array headers, not in `RenderState.h`; grep for them before writing the probe's expectations.
Reflection types (target `MG_State/GLState/ProgramState/ProgramArtifacts.h`), all nested inside `class ProgramObject` in `MobileGL/MG_State/GLState/ProgramState/ProgramObject.h`:
- `struct TypeFacts` **:44**
- `struct ResourceReflection` **:76**
- `struct LinkedShaderRef` **:150** (not in the doc's list of five)
- `struct XfbVarying` **:1146**
- `struct LinkArtifacts` **:1210**
- `struct SpirvArtifacts` **:1409**
- (also `PendingUniformWrite` **:1658**, `BackendHashMemoSlot` **:1767**, forward decls `ProgramLinkTask` **:20**, `ProgramSpirvTask` **:23**, `class ProgramObject` **:25**)
The doc's "five reflection types" = `TypeFacts`, `ResourceReflection`, `XfbVarying`, `LinkArtifacts`, `SpirvArtifacts` (`ARCHITECTURE.md:260`). **They are nested classes today** — extracting them out of `class ProgramObject` changes their qualified names (`ProgramObject::LinkArtifacts` → `ProgramArtifacts`-namespace name), which is what makes the "全套测试逐名不变(纯搬移)" and "nm 逐符号归因" acceptance gates non-trivial. Expect mangled-name churn on every function taking one of these by reference; that is exactly what the nm report of section 8 has to attribute.
### 4.3 The "7 includers" of `ProgramObject.h`
`grep -rln "ProgramState/ProgramObject.h\|\"ProgramObject.h\"" --include=*.h --include=*.cpp MobileGL` gives 10 files (one is the header itself):
```
MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h
MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp
MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp
MobileGL/MG_Impl/GLImpl/Program/ProgramInterface.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h
MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp
MobileGL/MG_State/GLState/ProgramState/ProgramObject.h (self)
MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h
MobileGL/MG_State/GLState/ProgramState/ProgramState.h
MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h
```
i.e. 9 other files, of which the doc counts 7 as needing an update (the two `MG_Backend/DirectVulkan/Renderer/*.cpp` and the `.h` all consume the reflection types; work out the exact 7 during implementation — the discrepancy is worth a line in the commit message).
---
## 5. Measured facts about `-H` (all commands run, all output real)
Environment: WSL Arch, `clang version 22.1.6` (`/usr/sbin/clang++`), `g++ (GCC) 16.1.1`, GNU `nm`/`size` at `/usr/sbin`. `which clang++` on the **Windows** shell returns nothing — the Windows box has no clang, so all local verification of this gate must happen in WSL (consistent with `ROADMAP.md:7` "Windows 机器不是正确性门").
### 5.1 `-H` goes to stderr, with dot-depth prefixes
```
$ clang++ -std=c++23 -I/tmp/hprobe -H -fsyntax-only probe.cpp 2>err.txt 1>out.txt
exit=0
stdout bytes: 0 ← nothing on stdout
err.txt:
. ./outer.h
.. ./sub/inner.h
... /usr/bin/../lib64/gcc/.../include/c++/16/vector
.... .../bits/requires_hosted.h
```
Format: `^(\.+) (path)$`; the number of dots is the include depth, so the **full inclusion chain to any forbidden header is reconstructible** by walking backwards to the nearest line with depth−1. That is the whole reason to prefer `-H` over `-MM`.
Paths come back **relative when the `-I` was relative or the include was resolved relative to the including file** (`. ./outer.h`, and `. MobileGL/MG_Pipe/MGPipeTypes.h` in the real run). The script must `os.path.realpath(os.path.join(compiler_cwd, path))` before matching prefixes.
`-H` prints a header **only on its first inclusion** (include guards suppress the rest) — irrelevant for a closure test, but it means the output is not an inclusion *count*.
### 5.2 `-MM` alternative
```
$ clang++ -std=c++23 -I/tmp/hprobe -MM probe.cpp
probe.o: probe.cpp outer.h sub/inner.h
```
One make rule, easy to parse, but it drops system headers and, critically, **loses the depth/chain information**. Use it only as a cross-check.
### 5.3 The real closure of `MGPipeTypes.h` today
Probe TU: `printf "#include <MG_Pipe/MGPipeTypes.h>\n" > probe_pipe.cpp`, flags copied verbatim from `build-linux-split/compile_commands.json` (the entry for `MobileGL/MG_State/GLState/Core.cpp`; 816 entries in that file), run with cwd = the build dir:
```
exit=0
total header lines: 666
in-tree MobileGL/: 20
MG_State/: 9
MG_Impl/: 0
MG_Backend/: 1
glslang: 18
spirv_cross: 2
SPIRV-Reflect: 0
```
The nine `MG_State` lines, with their real depths — this is the violation set the gate must report:
```
... MobileGL/MG_State/GLState/TextureState/TextureEnum.h (via MG_Backend/BackendObject.h)
.. MobileGL/MG_State/GLState/RenderState/RenderState.h ← MGPipeTypes.h:33, depth 2
... MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h
.... MobileGL/MG_State/GLState/TextureState/TextureObject.h
..... MobileGL/MG_State/GLState/TextureState/MipmapUploadTargetArray.h
......MobileGL/MG_State/GLState/TextureState/MipmapStorage.h
....... MobileGL/MG_State/GLState/TextureState/TextureTypes.h
..... MobileGL/MG_State/GLState/TextureState/../SamplerState/SamplerObject.h
.... MobileGL/MG_State/GLState/RenderbufferState/RenderbufferObject.h
```
Note the `TextureState/../SamplerState/SamplerObject.h` spelling — **`-H` prints the path as written, un-normalized**; prefix matching without `os.path.normpath` would miss a `../`-spelled violation. This is a real trap present in today's output.
The 666 total confirms (and slightly updates) the "661 headers" figure in `WireLog.h:12`.
### 5.4 A five-flag command reproduces the closure exactly — no build tree required
```bash
cd <repo root>
clang++ -std=gnu++23 \
-Iinclude -IMobileGL -IMobileGL/MG_Pipe \
-I3rdparty/xxHash -I3rdparty/Vulkan-Headers/include \
-H -E -o /dev/null probe_pipe.cpp
```
Result: `exit=0`, **666 header lines, 9 `MG_State/` — byte-identical to the full-flag run.** Adding `-DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO` changes nothing (666 either way).
Why so few flags: `include/glslang/` and `include/spirv_cross/` are **vendored into the repo** (not submodules) — `ls include/` gives `EGL GL GLES3 KHR android_debug.h glslang ska spirv_cross`, and the `-H` output shows `include/glslang/Include/Types.h` and `include/spirv_cross/spirv_cross_c.h` winning over the `3rdparty/` copies because `-Iinclude` precedes them in `MOBILEGL_INCLUDE_DIR`. The only submodule-provided headers in the closure are:
| header | provider | `.gitmodules` |
|---|---|---|
| `ska/flat_hash_map.hpp` (`Includes.h:53`) | `include/ska` | submodule `include/ska` |
| `xxhash.h` (`Includes.h:56`) | `3rdparty/xxHash` | submodule |
| `vulkan/vulkan.h` (`Includes.h:131`) | `3rdparty/Vulkan-Headers/include` | submodule |
So the CI job needs exactly `git submodule update --init include/ska 3rdparty/xxHash 3rdparty/Vulkan-Headers` — the `flatc-check` pattern (`test.yml:312-315`). No glslang submodule, no `update_glslang_sources.py`, no SPIRV-Cross, no flatbuffers, no CMake configure, no `${CMAKE_BINARY_DIR}/generated/MGGitHash.h`.
### 5.5 Speed, and `-E` vs `-fsyntax-only`, and `g++`
| command | wall time | header lines |
|---|---|---|
| `clang++ … -H -E -o /dev/null probe_pipe.cpp` | **0.137 s** | 666 |
| `clang++ … -H -fsyntax-only probe_pipe.cpp` | 1.889 s | 666 |
`-E` is ~14× faster and produces the identical closure, because the include graph is a preprocessor fact. **Use `-E -o /dev/null` for the closure assertion.** Keep one `-fsyntax-only` run per probe as a separate, second assertion ("the header is self-contained and compiles standing alone") — that is a genuinely different property and still under 2 s.
`g++` works too:
```
$ g++ -std=gnu++23 -Iinclude -IMobileGL -IMobileGL/MG_Pipe -I3rdparty/xxHash \
-I3rdparty/Vulkan-Headers/include -H -E -o /dev/null probe_pipe.cpp
exit=0 ; 651 header lines ; MG_State: 9
. MobileGL/MG_Pipe/MGPipeTypes.h
.. MobileGL/Includes.h
... MobileGL/Defines.h
.... /usr/include/signal.h
```
Same dot format, same 9 `MG_State` hits (the 651 vs 666 difference is entirely in libstdc++/glibc internals). **GCC trap**: `g++ -H` appends a trailer block
```
Multiple include guards may be useful for:
/usr/include/asm/errno.h
…
3rdparty/xxHash/xxhash.h
include/glslang/Include/../Include/visibility.h
```
— 54 non-dot lines in this run. The parser **must** accept only lines matching `^\.+ ` and discard everything else, or it will count a guard-advice line as a closure member. Clang emits no such trailer.
Since `g++` works, the gate does **not** have to `apt-get install clang-20` (~20 s): `g++` is preinstalled on every `ubuntu-latest` image. Recommendation is still to prefer clang-20 for parity with the real build (`test.yml:59-60`) and fall back to `c++`/`g++` — the script should take `--compiler` and print which one it used.
### 5.6 The closure of `ProgramObject.h` today, and why "no glslang" is not an assertable property
Same probe method, `#include <MG_State/GLState/ProgramState/ProgramObject.h>`:
```
total: 682 header lines ; in-tree: 33
. MobileGL/MG_State/GLState/ProgramState/ProgramObject.h
.. MobileGL/Includes.h
... MobileGL/Defines.h
... MobileGL/MG_Util/PlatformStubs.h
... MobileGL/MG_Util/Debug/Log.h
... MobileGL/MG_Util/Types.h
.... MobileGL/MG_Util/GLExtensions.h
.. MobileGL/MG_State/GLState/ProgramState/ShaderObject.h ← forbidden after P0.5
... MobileGL/MG_State/GLState/ProgramState/ShaderStage.h
... MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h
.... MobileGL/MG_Util/Async/JobNode.h
..... MobileGL/MG_State/GLState/ErrorState/ErrorCode.h
..... MobileGL/MG_State/GLState/ErrorState/ErrorInfo.h
.... MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h
..... MobileGL/Config.h
...... MobileGL/MG_Backend/BackendObjects.h
....... MobileGL/MG_Backend/BackendObject.h
........ MobileGL/MG_State/GLState/TextureState/TextureEnum.h
....... MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.h
........ MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h
....... MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h
........ MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h
.... MobileGL/MG_Util/ShaderTranspiler/Types.h
.... MobileGL/MG_State/GLState/BufferState/BufferState.h
..... MobileGL/MG_Util/Miscellany/IndexGenerator.h
..... MobileGL/MG_State/GLState/BufferState/BufferObject.h
...... MobileGL/MG_Util/Math/VectorTypes.h
...... MobileGL/MG_State/GLState/BufferState/PipeResource.h
.... MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h
..... MobileGL/MG_State/GLState/ProgramState/ShaderSourceKey.h
... MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h
.. MobileGL/MG_Util/Metrics/BufferMetrics.h
.. MobileGL/MG_Util/ShaderTranspiler/SpvcSession.h ← forbidden after P0.5
```
The whole middle block (`ShaderStage.h` … `ShaderSourceKey.h`) hangs off `ShaderObject.h` and disappears when that include goes. `Config.h` → `MG_Backend/BackendObjects.h` → **both backends' object headers** is the surprising edge: `ProgramObject.h` currently drags in `BackendObject_DirectGLES.h` *and* `BackendObject_DirectVulkan.h` *and* both loaders. Cutting `ShaderObject.h` cuts all of them.
**Critical design constraint.** `MobileGL/Includes.h` is the umbrella and it pulls, unconditionally:
- `:53` `<ska/flat_hash_map.hpp>`
- `:56` `<xxhash.h>`
- `:59` `<spirv_cross/spirv_cross_c.h>`
- `:79-83` `<glslang/Include/Types.h>`, `<glslang/Public/ShaderLang.h>`, `<glslang/SPIRV/GlslangToSpv.h>`, `<glslang/Include/intermediate.h>`, `<glslang/MachineIndependent/localintermediate.h>`
- `:131` `<vulkan/vulkan.h>`
- `:147-148` `"MG_Util/Debug/Log.h"`, `"MG_Util/Types.h"`
`ARCHITECTURE.md:260` explicitly permits `ProgramArtifacts.h` to include `<Includes.h>`. **Therefore an assertion of the form "the closure contains no glslang / spirv-cross / SPIRV-Reflect header" is unsatisfiable by construction and must not be written.** The task brief's proposed forbidden set has to be narrowed to in-tree paths:
- Probe A (`MG_Pipe/MGPipeValueTypes.h`): forbid `MobileGL/MG_State/`, `MobileGL/MG_Impl/`, `MobileGL/MG_Backend/`, `MobileGL/MG_Remote/`. (The doc only demands `MG_State/GLState`; the other three are free and match §16's layering.)
- Probe B (`MG_State/GLState/ProgramState/ProgramArtifacts.h`): forbid the exact files `ProgramState/ShaderObject.h` and `MG_Util/ShaderTranspiler/SpvcSession.h`, plus — because they are only reachable through those two — `MG_Util/ShaderTranspiler/`, `MobileGL/Config.h`, `MobileGL/MG_Backend/`, `MG_State/GLState/BufferState/`, `MG_State/GLState/ProgramState/Shader*.h`. Start with the doc's two names as the hard gate and add the rest as a second, tighter list once the header lands green.
If the project later wants the glslang-free assertion for real, the route is the `MG_Remote/Transport` route: **do not include `Includes.h`** (probe C, section 6.3), not a bigger forbidden list.
### 5.7 No macro-driven includes anywhere in `MobileGL/`
`grep -rn "^\s*#\s*include\s\+[A-Za-z_]" --include=*.h --include=*.cpp MobileGL` → **zero matches** (3377 `#include` lines total). Every include is a literal `"…"` or `<…>`. The only non-header includes are `MG_Pipe/MGPipe.h:65` `"PipeCalls.def"` and `:68,77,80,83,86,89,92` `"generated/Pipe*.inc"`.
This means a **pure-text transitive include walker in Python needs no compiler and cannot be fooled by macro spelling** — the basis for the fast layer-1 gate in section 6.2. It can still be fooled by `#if` (an include inside a false branch would be reported as present); the compiler probe is the arbiter.
---
## 6. Proposed design
Name it `scripts/check_include_closure.py`, and the CI job `include-graph-check` (the name `ARCHITECTURE.md:568` already reserves).
### 6.1 The probe manifest — data, not code
Put the probe definitions in the script as a module-level table so a reviewer can read the whole contract in one screen (the `gen_pipe.py` house style — see its `GENERATED_BANNER` and generator tables):
```python
PROBES = [
Probe(
Name = "value-header",
Header = "MG_Pipe/MGPipeValueTypes.h", # spelled as the TU will include it
Forbidden = ["MobileGL/MG_State/", "MobileGL/MG_Impl/",
"MobileGL/MG_Backend/", "MobileGL/MG_Remote/"],
Allow = [], # deliberately empty; see policy note
Why = "ROADMAP P0.5 / ARCHITECTURE.md:260 - it must not include MG_State/GLState",
),
Probe(
Name = "artifacts-header",
Header = "MG_State/GLState/ProgramState/ProgramArtifacts.h",
Forbidden = ["MobileGL/MG_State/GLState/ProgramState/ShaderObject.h",
"MobileGL/MG_Util/ShaderTranspiler/",
"MobileGL/Config.h",
"MobileGL/MG_Backend/"],
Allow = [],
Why = "ARCHITECTURE.md:260 - no ShaderObject.h (-> glslang), no SpvcSession.h",
),
Probe(
Name = "wire-header", # free bonus, green today
Header = "MG_Remote/Transport/ITransport.h",
Forbidden = ["MobileGL/Includes.h"],
Allow = [],
Why = "WireLog.h:9-24 / ITransport.h:25 - no GL umbrella in the byte-pipe layer",
),
]
```
Policy for `Allow`: copy the stdio gate's wording (`test.yml:831-835`) — **start empty; an addition needs a reason in the pull request, not a quiet whitelist entry.** If an entry is ever added, require a `Reason` string on it and print it in the summary so it stays visible.
`Forbidden` entries are matched as **path prefixes on the repo-relative, `os.path.normpath`-ed path** (so `TextureState/../SamplerState/SamplerObject.h` normalizes to `SamplerState/SamplerObject.h` and matches `MobileGL/MG_State/`). Any path outside the repo (system, `/usr/...`) is ignored.
### 6.2 Two modes, one script
**`--mode text` (default; no compiler, no submodules, ~50 ms).** Transitive walk of `#include` directives starting at the probe header. Resolution order per the CMake include dirs (`CMakeLists.txt:530-544`): (1) for `"quoted"` includes, the directory of the including file; (2) `include/`; (3) `MobileGL/`; (4) `MobileGL/MG_Pipe/`. Anything that does not resolve to a file inside the repo is a leaf (that is every `<vector>`, `<vulkan/vulkan.h>`, `<xxhash.h>`). Report a violation as `probe → chain → forbidden header`, using the recorded parent chain. This mode is safe in `pipe-gates` as-is.
**`--mode clang --compile-commands <path>` (authoritative).** Writes each probe TU into a temp dir, derives flags either from a `compile_commands.json` entry (take any entry whose `file` is under `MobileGL/`, strip `-o`, `-c`, `-MD/-MF/-MT`, and the input file; keep every `-D`, `-I`, `-isystem`, `-std`) or, when `--compile-commands` is absent, from the verified minimal set of 5.4. Runs
```
<cxx> <flags> -H -E -o /dev/null <probe.cpp>
```
capturing **stderr**, parses `^(\.+) (.*)$` only, normalizes each path against the compiler's cwd, and applies the same forbidden/allow logic. Then, as a second assertion, runs `-fsyntax-only` once per probe and fails if the header is not self-contained.
The two modes should agree; make a disagreement a hard failure with a message that says which mode saw what — that is a free consistency check on the text walker.
Both modes print, unconditionally, one machine-greppable summary line per probe in the house style:
```
include-closure: value-header OK 187 headers in closure, 0 forbidden (mode=clang, cxx=clang++-20)
include-closure: artifacts-header OK 642 headers in closure, 0 forbidden
include-closure: wire-header OK 8 headers in closure, 0 forbidden
include-closure: 3 probes, 0 problem(s)
```
and on failure, a chain, followed by `::error::` + `exit 1`:
```
include-closure: value-header FORBIDDEN MobileGL/MG_State/GLState/RenderState/RenderState.h
include-closure: chain: MG_Pipe/MGPipeValueTypes.h
include-closure: -> MobileGL/MG_State/GLState/RenderState/RenderState.h
include-closure: -> MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.h
include-closure: -> MobileGL/MG_State/GLState/TextureState/TextureObject.h
```
Flags to provide: `--mode {text,clang,both}`, `--compile-commands PATH`, `--compiler PATH` (default: `$CXX`, then `clang++-20`, then `clang++`, then `c++`), `--probe NAME` (run one), `--self-test`, `--json PATH` (dump the closure for the informational report), and — per the `check_doc_citations.py` precedent — a `--strict` if the gate is to land warning-only first (recommendation: it should NOT; it lands with the headers, green from commit one).
`REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))` — the `gen_pipe.py:37` / `check_doc_citations.py:33` idiom.
### 6.3 The third probe is free — take it
`MG_Remote/Transport/ITransport.h` includes only `"../Protocol/mg_protocol_base.h"` and `<cstdint>` (`:33,35`). Asserting "closure contains no `MobileGL/Includes.h`" is green today, costs ~30 ms, and pins the rule that `WireLog.h:9-24` says `Framing.h` has already violated once. It also gives the gate a probe that is green *before* P0.5 lands, so the script can be reviewed and merged independently of the header extraction.
### 6.4 The negative control as a real test, not a manual demo
`ROADMAP.md:7` — "每个门必须能因它存在的理由变红" — and `ROADMAP.md:16` — "人为加回一个 `MG_State` include 能变红" — require it. Make it a `--self-test` subcommand that **always runs as part of the gate**, so it cannot rot:
1. Synthesize a fourth, throwaway probe TU in the temp dir:
```cpp
#include <MG_Pipe/MGPipeValueTypes.h>
#include <MG_State/GLState/RenderState/RenderState.h> // deliberate violation
```
Run the *same* analysis over it with the *same* forbidden list.
2. Assert it reports **at least one** violation, and that the reported chain names `RenderState.h` at depth 1.
3. If it reports zero, the gate itself is broken → `::error::` + exit 1 with the message "negative control did not trip: the closure gate is not actually checking anything".
Do this **without touching any tracked file** — a synthesized TU in `tempfile.mkdtemp()`, never a temporary edit of a real header. (An earlier `git checkout`-based negative control would leave the tree dirty on a crash and race the `git diff --exit-code` step of the same job.)
Optionally strengthen it: also self-test the *text* walker and the *clang* mode separately, so a broken parser in one mode is caught even if the other mode still works.
For the "两条闭包断言" wording: the gate should also assert `len(green_probes) >= 2` for the two named headers, so deleting a probe from the manifest is itself red.
### 6.5 Wiring as a ctest
Add `MobileGL/MG_Test/Purity/CMakeLists.txt` and `add_subdirectory(Purity)` to `MobileGL/MG_Test/CMakeLists.txt` next to line 90 (`add_subdirectory(Pipe)`), with a comment in the same voice as 88-89:
```cmake
cmake_minimum_required(VERSION 3.14)
# The P0.5 include-closure assertions (ROADMAP P0.5, ARCHITECTURE.md:501 gate A). No
# compilation of MobileGL, no GL context: it preprocesses two probe TUs and reads
# clang's -H output. The build tree's own compile_commands.json supplies the real flags.
find_package(Python3 REQUIRED COMPONENTS Interpreter)
add_test(NAME MobileGLPurity.IncludeClosure
COMMAND ${Python3_EXECUTABLE}
${MGL_ROOT}/scripts/check_include_closure.py
--mode both
--compile-commands ${CMAKE_BINARY_DIR}/compile_commands.json
--compiler ${CMAKE_CXX_COMPILER}
--self-test)
set_tests_properties(MobileGLPurity.IncludeClosure PROPERTIES LABELS unit)
```
Notes and traps:
- `LABELS unit` is required or `ctest -L unit` (`test.yml:200,202`) will never run it. `--no-tests=error` protects only against an *empty* selection, not against a mislabelled test.
- `${CMAKE_BINARY_DIR}/compile_commands.json` exists because of `CMakeLists.txt:160`, but **it is written at the end of configure**; using it from a test (run time) is fine, from `execute_process` at configure time is not.
- Pass `${CMAKE_CXX_COMPILER}` so the test uses the same compiler that configured the build.
- Do **not** use `set_tests_properties(... ENVIRONMENT ...)` unless you must: `ARCHITECTURE.md:567` and `.github/workflows/test.yml:222-227` both record that a ctest `ENVIRONMENT` property *replaces* and *overrides* the job environment silently.
- This test travels in the `mobilegl-linux-runtime.tgz` only if the packaging list (`test.yml:126-137`) covers it: it packs `${BUILD_DIR}/MobileGL/MG_Test` and the root `CTestTestfile.cmake`, so a `MG_Test/Purity/CTestTestfile.cmake` **is** included — but the test then runs on the `test` job's runner, where `scripts/check_include_closure.py` exists (the job does `actions/checkout@v6` at 155-156) yet `3rdparty/` and `include/ska` do **not** (no submodules). In that environment `--mode clang` will fail on a missing `<ska/flat_hash_map.hpp>`. **Handle this explicitly**: either make the ctest `--mode text` only (always safe), or have the script detect the missing include roots and fail with a clear "run this from a full checkout" message rather than a compiler error. Recommendation: the ctest runs `--mode text --self-test` unconditionally and upgrades to `--mode both` only when the probe's include roots all exist.
### 6.6 Wiring as CI
**Preferred: a new standalone job `include-graph-check`**, modelled on `flatc-check`, inserted after the `flatc-check` job (i.e. after `test.yml:323`) so the file's ordering still reads gates-then-consumers:
```yaml
# The P0.5 interface-purity gate A (ARCHITECTURE.md:501): the two extracted headers'
# include closure, asserted on `-H` output rather than on symbols, because
# `nm --undefined-only` is blind to "included but not called". It needs a preprocessor
# and three submodules and nothing else - no CMake configure, no glslang sources - so
# it does not depend on build-linux, for the same reason pipe-gates does not.
include-graph-check:
name: Include-closure purity gate
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
- name: Check out the three header submodules the closure needs
# ska/flat_hash_map.hpp, xxhash.h and vulkan/vulkan.h are the only submodule
# headers Includes.h reaches; glslang and spirv_cross are vendored under include/.
run: git submodule update --init include/ska 3rdparty/xxHash 3rdparty/Vulkan-Headers
- name: Install clang
run: sudo apt-get update && sudo apt-get install -y clang-20
- name: Include-closure assertions and negative control
run: python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test
```
Drop the `Install clang` step and pass `--compiler g++` if the ~20 s apt cost is unwanted — 5.5 proves `g++ -H` gives the same in-tree answer, at the cost of not matching the shipping compiler.
**Alternative, if a separate job is unwanted:** add the two steps to `pipe-gates` after line 848 (before the citation lint). `pipe-gates` currently has no submodules and no compiler, so this adds a `git submodule update --init` and an apt install to a job whose comment (812-813) advertises "they take seconds". `--mode text` alone would preserve that property with zero new dependencies; `--mode both` would not. If the implementer wants exactly one job, run `--mode text --self-test` in `pipe-gates` and `--mode clang` in the new job.
**Do not forget** `test.yml:3-8`: add `feat/disaggregated` to the `push: branches:` list, or the gate will not run until the branch merges.
---
## 7. Summary of design decisions and their evidence
| decision | evidence |
|---|---|
| Gate name `include-graph-check` | `ARCHITECTURE.md:568` reserves it |
| `-H`, not `-MM` | `-H` carries depth → printable chain (5.1 vs 5.2); `WireLog.h:17-19` says the gate "asserts on `-H` output" |
| `-E -o /dev/null`, not `-fsyntax-only`, for the closure | 0.137 s vs 1.889 s, identical 666 lines (5.5) |
| Parse only `^\.+ ` lines | `g++ -H` appends 54 guard-advice lines (5.5) |
| `os.path.normpath` before prefix match | today's output contains `TextureState/../SamplerState/SamplerObject.h` (5.3) |
| Forbidden set = in-tree prefixes only, **not** glslang/spirv-cross | `Includes.h:59,79-83` pulls them unconditionally and `ARCHITECTURE.md:260` lets `ProgramArtifacts.h` keep `<Includes.h>` (5.6) |
| Selective submodules `include/ska 3rdparty/xxHash 3rdparty/Vulkan-Headers` | the only submodule headers in the closure; glslang/spirv_cross are vendored under `include/` (5.4); pattern from `test.yml:312-315` |
| No CMake configure needed in CI | five-flag command reproduces the closure exactly (5.4) |
| `compile_commands.json` still used, for the ctest | `CMakeLists.txt:160` makes it unconditional; 816 entries verified |
| Negative control as an always-on `--self-test` on a synthesized TU | `ROADMAP.md:7` "每个门必须能因它存在的理由变红"; `ROADMAP.md:16` "人为加回一个 `MG_State` include 能变红" |
| Empty allow-list, additions need a PR reason | the stdio gate's policy, `test.yml:831-835` |
| Third probe on `ITransport.h` | free, green today, locks the rule `Framing.h` already broke (`WireLog.h:17-20`) |
| `LABELS unit` on the ctest | `test.yml:198-203` runs `ctest -L unit --no-tests=error` |
---
## 8. The nm / `.text` per-symbol attribution report
Requirement, `ROADMAP.md:16`: "`nm`/`.text` 变化可逐符号归因". Home for the output, `ARCHITECTURE.md:507`: "符号与 `.text` 漂移每阶段作为信息性指标发布". Name reserved by `ARCHITECTURE.md:568`: `monolith-symbol-report`.
There is **no existing nm/size tooling in the tree** — `grep -rn "nm --defined\|nm -D\|size --format\|llvm-nm\|readelf"` over `*.yml *.py *.sh *.md` outside `docs/Disaggregated/` returns nothing. This is greenfield.
### 8.1 Verified commands (run against `~/w7/pipe/build-linux/libMobileGL.so`, 19,101,056 bytes)
```bash
$ nm --defined-only libMobileGL.so | wc -l
30511
$ nm --defined-only -S --size-sort libMobileGL.so | tail -3
0000000000d8abdc 0000000000026fe4 r __GNU_EH_FRAME_HDR
00000000007ffd80 000000000003e859 T _Z7yyparsePN7glslang13TParseContextE
0000000000f302e0 00000000000fa000 b _ZZN8MobileGL7MG_Util5Debug3LogEPKciS3_zE6buffer
$ nm --defined-only -S -C libMobileGL.so | grep -E " t | T " | head -3
0000000000c13240 0000000000000013 t atexit
00000000001d1c90 000000000000000b t __clang_call_terminate
0000000000ae9d20 0000000000004453 t CreateShaderModule
$ size --format=sysv libMobileGL.so | grep -E "^\.text|^Total"
.text 10792579 1868752
Total 17209819
```
So: `nm --defined-only -S` yields `addr size type name` per symbol (the per-symbol `.text` attribution); `size --format=sysv` yields the section roll-up (`.text` = 10,792,579 bytes here). Both are the right tools; note `size --format=sysv` is **per-section, never per-symbol** — the per-symbol half must come from `nm -S`.
### 8.2 Proposed `scripts/symbol_report.py`
```
python3 scripts/symbol_report.py --before <dir-or-so> --after <dir-or-so> \
[--nm nm] [--size size] [--markdown out.md] [--json out.json] [--threshold 0]
```
For each of `libMobileGL.so` (and, when present, `libMobileGL_s.a` / `MobileGLServer`):
1. `nm --defined-only -S <so>` → `{mangled_name: (type, size)}`. Keep the mangled name as the key (stable), carry a demangled form from `nm -C` for display only. Symbols with no size (e.g. `U`, absolute) are dropped.
2. `size --format=sysv <so>` → per-section sizes, for the roll-up header.
3. Diff into four buckets, each sorted by `|Δsize|` descending:
- **removed** (in before, absent in after)
- **added** (absent in before, in after)
- **resized** (same name, different size)
- **unchanged** (count only)
4. Print a summary line and a Markdown table:
```
symbol-report: .text 10792579 -> 10794112 (+1533, +0.014%)
symbol-report: 30511 -> 30514 defined symbols: 6 added, 3 removed, 11 resized
```
5. **Attribution.** For P0.5 specifically, the interesting bucket is *renames caused by de-nesting*: `ProgramObject::LinkArtifacts` → a namespace-scope `LinkArtifacts`. Give the script a `--rename-map old=new` (repeatable) or a `--demangle-normalize` pass that strips `ProgramObject::` before matching, so a pure move shows up as **0 added / 0 removed / N renamed, byte-identical sizes** rather than as N+N churn. That is precisely what "逐符号归因" means for this phase, and without it the report will be an unreadable wall of mangled churn.
6. Exit 0 always in the informational form; provide `--fail-on-added-symbol-bytes N` for a future hard gate. `ARCHITECTURE.md:507` classes this as informational for now.
Guard rails to encode in the script and its docstring:
- Compare only builds with the same `CMAKE_BUILD_TYPE` — the visibility presets differ between Debug and non-Debug (`CMakeLists.txt:572-583`, `622-635`), which changes `nm --defined-only` output wholesale.
- `MOBILEGL_ENABLE_LTO` (default OFF, `CMakeLists.txt:108`) must be OFF on both sides; `-ffunction-sections`/`--gc-sections` only appear with it (137-146).
- Note in the output which compiler and build type produced each side; a clang-20 vs clang-22 comparison is meaningless.
### 8.3 Where the output goes
A `monolith-symbol-report` job, `needs: [build-linux]` plus a second build of the *base* commit — or, cheaper for P0.5: a step inside `build-linux` that runs the script against the artifact of the previous run is not available without extra plumbing, so the practical first version is **local**: the implementer builds `HEAD~1` and `HEAD` in two WSL build dirs and runs the script, pasting the summary into the commit message / PR body, with the full Markdown attached as a CI artifact when the job exists.
For the CI form, follow the existing artifact idiom (`test.yml:140-146`, `290-296`):
```yaml
- name: Upload symbol report
uses: actions/upload-artifact@v7
with:
name: monolith-symbol-report
path: ci-artifacts/symbol-report.md
if-no-files-found: error
```
and additionally echo the summary into `$GITHUB_STEP_SUMMARY` so the numbers are visible without downloading (no existing job does this yet — it would be a new, welcome idiom for an informational metric).
---
## 9. Open items for the implementer
1. **`feat/disaggregated` is not in `test.yml:3-8`'s trigger list.** Any gate added now is dormant until that changes or someone runs `workflow_dispatch`. Fix this in the same commit or the gate's "green" is meaningless.
2. **Decide the two forbidden lists precisely** before writing the headers — `ARCHITECTURE.md:260` gives the minimum (`MG_State/GLState` for probe A; `ShaderObject.h` + `SpvcSession.h` for probe B); section 6.1 proposes tighter supersets. A too-tight list that the extracted header cannot satisfy will stall P0.5.
3. **`MAX_DRAW_BUFFERS`, `SamplerParameters`, `BorderColorForm`, `VertexAttribute`, `VertexBufferBindingPoint`** are named in `ARCHITECTURE.md:260` but are not in `RenderState.h`; locate them and check whether *their* current homes pull `MG_State/GLState` transitively, because they move into the same value header.
4. **The five reflection types are nested in `class ProgramObject`** (`ProgramObject.h:44,76,1146,1210,1409`). De-nesting them renames every mangled symbol that mentions them — plan the `--rename-map` of 8.2 before running the first report, or the "逐符号归因" gate will look catastrophic.
5. `ARCHITECTURE.md:563` cites `MobileGL/MG_Test/CMakeLists.txt:93-95` for the `MOBILEGL_BUILD_DISAGGREGATED` guard; in this worktree it is at **100-103**. Re-pin that citation when the doc lint goes `--strict` (`test.yml:849-861`).
6. Consider whether the gate should also run in the `MOBILEGL_BUILD_DISAGGREGATED=1` configuration. The closure of `MGPipeTypes.h` was measured with `-DMOBILEGL_BUILD_DISAGGREGATED=1` present and absent and was 666 lines either way (5.4), so today it makes no difference — but `Init.cpp`'s branch and `MG_Config::Transport` do change, so a probe that ever reaches `Config.h` would. Run both, it costs 0.3 s.