diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index 749ffe6f6..4ecb4db44 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -101,16 +101,17 @@ jobs: ccache --version ccache --show-config + # Graph-affecting build options (BUILD_VIZ / BUILD_PLUGIN_OAK_CAMERA / + # ENABLE_CLOUDXR_BUNDLE_CHECK / plugins / examples / tests / python bindings) live in the + # `ci-linux` preset in CMakePresets.json, the single source shared with the + # cmake-target-layers diagram workflow. Only the per-matrix/tooling vars are overlaid here. - name: Configure CMake run: | - cmake -B build \ + cmake --preset ci-linux \ -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ -DISAAC_TELEOP_PYTHON_VERSION=${{ matrix.python_version }} \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ - -DBUILD_PLUGIN_OAK_CAMERA=ON \ - -DBUILD_VIZ=ON \ - -DENABLE_CLOUDXR_BUNDLE_CHECK=ON \ -DBUNDLE_ROBOTIC_GROUNDING=${{ steps.setup-v2d-src.outputs.bundled || 'false' }} - name: Build diff --git a/.github/workflows/cmake-target-layers.yaml b/.github/workflows/cmake-target-layers.yaml new file mode 100644 index 000000000..025d5b136 --- /dev/null +++ b/.github/workflows/cmake-target-layers.yaml @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Verify that cmake/cmake-target-layers.md matches the live CMake target graph. +# +# The graph is read straight from CMake: we configure with the SAME `ci-linux` preset that the +# primary Linux build uses (CMakePresets.json is the single source of the graph-affecting build +# options), ask CMake to emit its own dependency graph (`cmake --graphviz`), and regenerate the +# layered diagram. If the committed copy drifts from what CMake reports, the job fails — and the +# freshly-generated diagram is always uploaded as the `cmake-target-layers` artifact, so the fix +# is to download it and commit it in place of the stale file (no local full build required). +# +# Only graph-NEUTRAL build gates are relaxed here (ENABLE_CLOUDXR_BUNDLE_CHECK and +# ENABLE_CLANG_FORMAT_CHECK gate python/utility/custom targets that `cmake --graphviz` excludes), +# which keeps this job free of the CloudXR SDK / NGC secret so it also runs on fork PRs. Every +# build-affecting flag still comes from the shared preset, so the diagram cannot diverge from the +# real build's target graph. + +name: Verify CMake target layers + +on: + push: + branches: [ main, 'release/*.*.x' ] + paths: + - '**/CMakeLists.txt' + - '**/*.cmake' + - 'CMakePresets.json' + - 'scripts/cmake_target_layers.py' + - 'cmake/cmake-target-layers.md' + - '.github/workflows/cmake-target-layers.yaml' + pull_request: + paths: + - '**/CMakeLists.txt' + - '**/*.cmake' + - 'CMakePresets.json' + - 'scripts/cmake_target_layers.py' + - 'cmake/cmake-target-layers.md' + - '.github/workflows/cmake-target-layers.yaml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + verify-target-layers: + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 # Full history for accurate git describe during configure + + - name: Install uv + uses: ./.github/actions/setup-uv + + # viz_core links libcudart, so find_package(CUDAToolkit) must resolve at configure time + # for BUILD_VIZ=ON (set by the ci-linux preset). + - name: Install CUDA toolkit + uses: ./.github/actions/setup-cuda + + - name: Install Apt dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake libx11-dev libvulkan-dev glslang-tools \ + libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev libxkbcommon-dev \ + libwayland-dev wayland-protocols + + # BUILD_PLUGIN_OAK_CAMERA=ON (ci-linux preset) pulls DepthAI via Hunter at configure time. + - name: Cache Hunter packages + uses: actions/cache@v5 + with: + path: ~/.hunter + key: hunter-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('cmake/SetupHunter.cmake') }} + restore-keys: | + hunter-${{ runner.os }}-${{ runner.arch }}- + + # Configure only (no build) with the shared preset; emit CMake's own dependency graph. + # The two -D overlays are graph-neutral (see header) and drop the CloudXR/clang-format needs. + - name: Configure CMake and emit dependency graph + run: | + cmake --preset ci-linux \ + -DENABLE_CLOUDXR_BUNDLE_CHECK=OFF \ + -DENABLE_CLANG_FORMAT_CHECK=OFF \ + --graphviz=build/cmake-target-graph.dot + + - name: Regenerate diagram (artifact) + run: | + python3 scripts/cmake_target_layers.py \ + --dot build/cmake-target-graph.dot \ + --out artifact/cmake-target-layers.md + + # Upload regardless of the check result so a stale committed copy can be replaced by this file. + - name: Upload diagram artifact + if: always() + uses: actions/upload-artifact@v6 + with: + name: cmake-target-layers + path: artifact/cmake-target-layers.md + if-no-files-found: error + + - name: Verify committed diagram is up to date + id: verify-diagram + run: | + python3 scripts/cmake_target_layers.py \ + --dot build/cmake-target-graph.dot \ + --check + + - name: Hint on stale diagram + if: failure() && steps.verify-diagram.conclusion == 'failure' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + if [ -n "$PR_URL" ]; then + HINT="Comment \`/update-cmake-target-layers\` on the PR to auto-commit the refreshed diagram." + else + HINT="Download the \`cmake-target-layers\` artifact from this run and commit it in place of \`cmake/cmake-target-layers.md\`." + fi + echo "::error title=cmake-target-layers.md is stale::$HINT" diff --git a/.github/workflows/update-cmake-target-layers.yaml b/.github/workflows/update-cmake-target-layers.yaml new file mode 100644 index 000000000..83c261b6d --- /dev/null +++ b/.github/workflows/update-cmake-target-layers.yaml @@ -0,0 +1,186 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Slash-command handler: /update-cmake-target-layers +# +# When a maintainer with write access comments `/update-cmake-target-layers` on a PR, +# this workflow downloads the cmake-target-layers artifact that the "Verify CMake target +# layers" CI job already generated on its most recent successful run and commits it to +# the PR branch via the GitHub Contents API. No checkout of PR code, no cmake re-run. +# +# Only same-repo PRs are supported; fork PRs cannot be pushed to from this context. +# +# The workflow file is always loaded from the default branch, so a PR cannot override +# it to gain elevated write-access. + +name: Update CMake target layers + +on: + issue_comment: + types: [created] + +jobs: + update: + name: Commit refreshed cmake-target-layers.md + if: >- + github.event_name == 'issue_comment' + && github.event.issue.pull_request != null + && (github.event.comment.body == '/update-cmake-target-layers' + || startsWith(github.event.comment.body, '/update-cmake-target-layers ')) + runs-on: ubuntu-latest + concurrency: + group: update-cmake-target-layers-pr-${{ github.event.issue.number }} + cancel-in-progress: true + permissions: + actions: read # download artifact from the verify run + contents: write # push to the PR branch + pull-requests: write # comment on the PR + issues: write # react to the triggering comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + COMMENT_ID: ${{ github.event.comment.id }} + PR_NUMBER: ${{ github.event.issue.number }} + + steps: + - name: Verify commenter has write access + env: + USER: ${{ github.event.comment.user.login }} + run: | + PERM=$(gh api "repos/$REPO/collaborators/$USER/permission" --jq '.permission') + case "$PERM" in + admin|maintain|write) + echo "$USER has $PERM access — proceeding." + ;; + *) + gh api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content='-1' >/dev/null + echo "::error::User $USER has '$PERM' access; /update-cmake-target-layers requires write or higher." >&2 + exit 1 + ;; + esac + + - name: Acknowledge command + run: | + gh api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content='eyes' >/dev/null + + - name: Get PR info + id: pr + run: | + read -r HEAD_SHA HEAD_REF HEAD_REPO < <( + gh api "repos/$REPO/pulls/$PR_NUMBER" \ + --jq '[.head.sha, .head.ref, .head.repo.full_name] | @tsv' + ) + + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT" + + if [ "$HEAD_REPO" != "$REPO" ]; then + gh api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content='-1' >/dev/null + gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ + "❌ \`/update-cmake-target-layers\` only works for same-repository PRs. Fork PRs cannot be pushed to from this context. Download the \`cmake-target-layers\` artifact from the failing CI run and commit it manually." + echo "::error::Fork PR — cannot push to $HEAD_REPO from $REPO context." >&2 + exit 1 + fi + + # Reuse the artifact the verify job already uploaded on its successful run. + # Only success runs are eligible: a failure run may have crashed before the + # Regenerate step and therefore has no artifact to download. + - name: Find verify run for PR head SHA + id: find-run + env: + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + RUN_ID=$(gh run list --repo "$REPO" \ + --workflow=cmake-target-layers.yaml \ + --commit "$HEAD_SHA" \ + --limit 10 \ + --json databaseId,conclusion \ + --jq '[.[] | select(.conclusion == "success")] | .[0].databaseId // ""') + + if [ -z "$RUN_ID" ]; then + gh api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content='-1' >/dev/null + gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ + "❌ No successful **Verify CMake target layers** run found for commit \`${HEAD_SHA:0:8}\`. Wait for the CI job to pass, then re-comment \`/update-cmake-target-layers\`." + echo "::error::No successful cmake-target-layers.yaml run for $HEAD_SHA" >&2 + exit 1 + fi + + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + echo "Reusing artifact from cmake-target-layers.yaml run #$RUN_ID for SHA \`${HEAD_SHA:0:8}\`" \ + >> "$GITHUB_STEP_SUMMARY" + + - name: Download cmake-target-layers artifact + uses: actions/download-artifact@v7 + with: + name: cmake-target-layers + path: ./artifact + run-id: ${{ steps.find-run.outputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + # Commit via the Contents API — no checkout of PR code required. + # This avoids the "untrusted checkout in a privileged context" security pattern. + - name: Commit refreshed diagram via API + id: commit + env: + COMMENTER: ${{ github.event.comment.user.login }} + COMMENTER_ID: ${{ github.event.comment.user.id }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + RUN_ID: ${{ steps.find-run.outputs.run_id }} + run: | + # Get blob SHA of the currently-committed file (required to update it via API). + FILE_META=$(gh api "repos/$REPO/contents/cmake/cmake-target-layers.md?ref=$HEAD_REF" \ + 2>/dev/null || echo '{}') + CURRENT_SHA=$(echo "$FILE_META" | jq -r '.sha // ""') + + # Compare base64 strings directly — API returns base64 with line breaks, strip them. + ARTIFACT_B64=$(base64 -w 0 ./artifact/cmake-target-layers.md) + CURRENT_B64=$(echo "$FILE_META" | jq -r '.content // ""' | tr -d '\n') + if [ "$ARTIFACT_B64" = "$CURRENT_B64" ]; then + echo "already_current=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + COMMIT_MSG=$(printf \ + 'docs(cmake): regenerate cmake-target-layers.md\n\nAuto-committed by /update-cmake-target-layers (PR #%s).\nSource: cmake-target-layers.yaml run #%s, head %s.' \ + "$PR_NUMBER" "$RUN_ID" "$HEAD_SHA") + + jq -n \ + --arg message "$COMMIT_MSG" \ + --arg content "$ARTIFACT_B64" \ + --arg sha "$CURRENT_SHA" \ + --arg branch "$HEAD_REF" \ + --arg name "$COMMENTER" \ + --arg email "${COMMENTER_ID}+${COMMENTER}@users.noreply.github.com" \ + '{message: $message, content: $content, branch: $branch, + committer: {name: $name, email: $email}, + author: {name: $name, email: $email}} + | if $sha != "" then . + {sha: $sha} else . end' \ + | gh api -X PUT "repos/$REPO/contents/cmake/cmake-target-layers.md" --input - + + - name: Confirm update + if: always() + env: + ALREADY_CURRENT: ${{ steps.commit.outputs.already_current }} + COMMIT_OUTCOME: ${{ steps.commit.outcome }} + run: | + if [ "$COMMIT_OUTCOME" = "success" ]; then + gh api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content='+1' >/dev/null + if [ "$ALREADY_CURRENT" = "true" ]; then + gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ + "✅ \`cmake/cmake-target-layers.md\` is already up to date with the latest CI run — nothing to commit." + else + gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ + "✅ \`cmake/cmake-target-layers.md\` has been refreshed and committed to this branch." + fi + else + gh api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content='-1' >/dev/null + gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ + "❌ Failed to commit \`cmake/cmake-target-layers.md\` — check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details." + fi diff --git a/AGENTS.md b/AGENTS.md index 2e28d7f3b..4f91bd0ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ only point here — edit the rules in the doc, not the shims. clang-format --dry-run --Werror $(git diff --name-only main -- '*.cpp' '*.hpp' '*.h' '*.cc') ``` +- If a formatter hook rewrites files (for example `ruff format`), keep the mechanical rewrite and rerun the full pre-commit command until it passes. - If a hook failure shows **missing or non-obvious repo policy** (not a one-off typo), you **must** add a **short** reminder under **Mandatory learning loop** rules to the right `AGENTS.md` or adjacent **`//` comments** so the next run does not repeat it—unless it is already documented. ## Mandatory learning loop (AGENTS.md and comments) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 000000000..ad63a8fef --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,25 @@ +{ + "version": 3, + "cmakeMinimumRequired": { + "major": 3, + "minor": 21, + "patch": 0 + }, + "configurePresets": [ + { + "name": "ci-linux", + "displayName": "Linux CI build (full)", + "description": "Canonical Linux CI configuration. Single source of truth for the graph-affecting build options, shared with the cmake-target-layers diagram workflow.", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "BUILD_VIZ": "ON", + "BUILD_PLUGINS": "ON", + "BUILD_PLUGIN_OAK_CAMERA": "ON", + "BUILD_EXAMPLES": "ON", + "BUILD_TESTING": "ON", + "BUILD_PYTHON_BINDINGS": "ON", + "ENABLE_CLOUDXR_BUNDLE_CHECK": "ON" + } + } + ] +} diff --git a/CMakePresets.json.license b/CMakePresets.json.license new file mode 100644 index 000000000..817df2442 --- /dev/null +++ b/CMakePresets.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 diff --git a/cmake/cmake-structure.md b/cmake/cmake-structure.md index 9bf506cc5..e12abef10 100644 --- a/cmake/cmake-structure.md +++ b/cmake/cmake-structure.md @@ -17,6 +17,11 @@ as humans. Apply them both when **authoring** code/build files and when > and the root `AGENTS.md`) point here — edit the rules **here**, not in those > shims. +> **See also:** [`cmake-target-layers.md`](cmake-target-layers.md) — an auto-generated, +> layered view of the *actual* CMake target dependency graph (direct edges only, sorted into +> topological layers), regenerated from live CMake and CI-verified by the *Verify CMake target +> layers* workflow. + ## The six rules (non-negotiable) 1. **One CMake target per leaf directory.** A directory that defines a target diff --git a/cmake/cmake-target-layers.md b/cmake/cmake-target-layers.md new file mode 100644 index 000000000..7c7260d61 --- /dev/null +++ b/cmake/cmake-target-layers.md @@ -0,0 +1,296 @@ + + + +# CMake target dependency layers + +Layered, **direct-dependency** view of this project's CMake targets, derived from live +CMake (`cmake --graphviz`). Targets are sorted into topological layers: a target sits one +layer above its deepest direct dependency, so no two targets in a layer depend on each +other and dependencies always point to lower layers. + +> **Auto-generated -- do not edit the region between the markers below.** +> Regenerate by replacing this file with the `cmake-target-layers` artifact from the +> *Verify CMake target layers* CI run, or locally with the full CI toolchain via +> `python3 scripts/cmake_target_layers.py --preset ci-linux --write`. +> CI fails when the committed diagram drifts from what CMake reports. + + + +## Overview + +- **59** targets, **98** direct dependencies, **8** layers. +- Generated from configure preset `ci-linux` (see `CMakePresets.json`). +- Layer *k* contains targets whose deepest direct-dependency chain is *k* long; every dependency points to a strictly lower layer, so there are **no edges within a layer**. This is a layered DAG (shared foundations create diamonds), not a strict tree. +- Raw system library links (`-ldl`, `-lstdc++fs`, …) are omitted: CMake records them as *Unknown library* nodes (not real CMake targets) and they carry no structural information about module boundaries. +- Third-party nodes that no first-party target links directly are omitted (e.g. `pybind11::pybind11`, `Catch2`, `ProjectConfig`). Only the top-level API surface that this project actually links against is shown; internal sub-targets of third-party packages are implementation details of those packages. A small set of individually-named build-machinery targets (see `HIDDEN_TARGETS` in the generator script) are also omitted — these are programmatically injected by CMake helper functions and do not represent user-authored dependency choices. +- **Transitive reduction applied:** edges that are already implied by a longer dependency path are omitted (e.g. if A → B → C, the redundant A → C edge is dropped). The graph has the same reachability as the raw CMake declarations; see the `CMakeLists.txt` files for every declared `target_link_libraries` call. + +### Legend + +- Node shape: `([executable])`, `[static / shared library]`, `[[module library]]`, `{{interface library}}`, `[/object library/]`, `[custom target]`. +- Node colour: blue = first-party target, grey = third-party dependency. +- Arrow `A --> B` means **A depends on B** (B is in a lower layer). +- Link visibility (`public` / `private` / `interface`) is listed in the per-target table below. + +## Layered dependency graph + +```mermaid +%%{init: {"flowchart": {"rankSpacing": 120}, "themeVariables": {"fontSize": "24pt"}} }%% +flowchart TD + subgraph LYR7["Layer 7 - top (consumers)"] + deviceio_session_py[["deviceio_session_py"]] + viz_layers_tests(["viz_layers_tests"]) + viz_session_tests(["viz_session_tests"]) + end + subgraph LYR6["Layer 6"] + deviceio__deviceio_py_utils{{"deviceio::deviceio_py_utils"}} + controller_synthetic_hands(["controller_synthetic_hands"]) + frame_metadata_printer(["frame_metadata_printer"]) + mcap_tests(["mcap_tests"]) + oxr_session_sharing(["oxr_session_sharing"]) + oxr_simple_api_demo(["oxr_simple_api_demo"]) + pedal_printer(["pedal_printer"]) + replay_deviceio_session_tests(["replay_deviceio_session_tests"]) + teleop_ros2_mcap_generator(["teleop_ros2_mcap_generator"]) + viz__layers_testing["viz::layers_testing"] + viz_py[["viz_py"]] + end + subgraph LYR5["Layer 5"] + deviceio__deviceio_session["deviceio::deviceio_session"] + viz__layers["viz::layers"] + end + subgraph LYR4["Layer 4"] + deviceio__live_trackers["deviceio::live_trackers"] + deviceio__replay_trackers["deviceio::replay_trackers"] + deviceio_trackers_py[["deviceio_trackers_py"]] + viz__session["viz::session"] + viz_xr_tests(["viz_xr_tests"]) + viz_core_tests(["viz_core_tests"]) + end + subgraph LYR3["Layer 3"] + deviceio__deviceio_trackers["deviceio::deviceio_trackers"] + camera_plugin_oak(["camera_plugin_oak"]) + generic_3axis_pedal_plugin(["generic_3axis_pedal_plugin"]) + pedal_pusher(["pedal_pusher"]) + oxr_py[["oxr_py"]] + viz__xr["viz::xr"] + viz__test_support{{"viz::test_support"}} + xdev_list(["xdev_list"]) + end + subgraph LYR2["Layer 2"] + pusherio__pusherio["pusherio::pusherio"] + deviceio__deviceio_base{{"deviceio::deviceio_base"}} + oxr__oxr_core["oxr::oxr_core"] + mcap__mcap_core{{"mcap::mcap_core"}} + schema_py[["schema_py"]] + Teleop__plugin_utils["Teleop::plugin_utils"] + viz__core["viz::core"] + examples_common["examples_common"] + plugin_manager_py[["plugin_manager_py"]] + schema_tests(["schema_tests"]) + viz_shaders_tests(["viz_shaders_tests"]) + end + subgraph LYR1["Layer 1"] + oxr__oxr_utils{{"oxr::oxr_utils"}} + isaacteleop_schema{{"isaacteleop_schema"}} + OpenXR__openxr_loader["OpenXR::openxr_loader"] + teleop_plugin_manager["teleop_plugin_manager"] + depthai__core["depthai::core"] + glfw["glfw"] + Catch2__Catch2WithMain["Catch2::Catch2WithMain"] + end + subgraph LYR0["Layer 0 - foundation"] + OpenXR__headers{{"OpenXR::headers"}} + flatbuffers["flatbuffers"] + yaml_cpp__yaml_cpp["yaml-cpp::yaml-cpp"] + Threads__Threads{{"Threads::Threads"}} + Catch2__Catch2["Catch2::Catch2"] + SDL2__SDL2_static["SDL2::SDL2-static"] + glm__glm{{"glm::glm"}} + mcap__mcap{{"mcap::mcap"}} + pybind11__module{{"pybind11::module"}} + Teleop__openxr_extensions{{"Teleop::openxr_extensions"}} + viz__shaders{{"viz::shaders"}} + end + Catch2__Catch2WithMain --> Catch2__Catch2 + camera_plugin_oak --> SDL2__SDL2_static + camera_plugin_oak --> depthai__core + camera_plugin_oak --> isaacteleop_schema + camera_plugin_oak --> mcap__mcap + camera_plugin_oak --> oxr__oxr_core + camera_plugin_oak --> pusherio__pusherio + controller_synthetic_hands --> deviceio__deviceio_session + controller_synthetic_hands --> oxr__oxr_core + controller_synthetic_hands --> Teleop__plugin_utils + depthai__core --> Threads__Threads + deviceio__deviceio_base --> isaacteleop_schema + deviceio__deviceio_py_utils --> deviceio__deviceio_session + deviceio__deviceio_session --> deviceio__live_trackers + deviceio__deviceio_session --> deviceio__replay_trackers + deviceio_session_py --> deviceio__deviceio_py_utils + deviceio_session_py --> pybind11__module + deviceio__deviceio_trackers --> deviceio__deviceio_base + deviceio_trackers_py --> deviceio__deviceio_trackers + deviceio_trackers_py --> pybind11__module + examples_common --> OpenXR__openxr_loader + frame_metadata_printer --> deviceio__deviceio_session + frame_metadata_printer --> oxr__oxr_core + generic_3axis_pedal_plugin --> isaacteleop_schema + generic_3axis_pedal_plugin --> oxr__oxr_core + generic_3axis_pedal_plugin --> pusherio__pusherio + glfw --> Threads__Threads + isaacteleop_schema --> flatbuffers + deviceio__live_trackers --> deviceio__deviceio_trackers + deviceio__live_trackers --> mcap__mcap_core + deviceio__live_trackers --> oxr__oxr_utils + deviceio__live_trackers --> Teleop__openxr_extensions + mcap__mcap_core --> isaacteleop_schema + mcap__mcap_core --> mcap__mcap + mcap_tests --> Catch2__Catch2WithMain + mcap_tests --> deviceio__deviceio_session + OpenXR__openxr_loader --> Threads__Threads + OpenXR__openxr_loader --> OpenXR__headers + oxr__oxr_core --> OpenXR__openxr_loader + oxr__oxr_core --> oxr__oxr_utils + oxr_py --> oxr__oxr_core + oxr_py --> pybind11__module + oxr_session_sharing --> deviceio__deviceio_session + oxr_session_sharing --> oxr__oxr_core + oxr_simple_api_demo --> deviceio__deviceio_session + oxr_simple_api_demo --> oxr__oxr_core + oxr__oxr_utils --> OpenXR__headers + pedal_printer --> deviceio__deviceio_session + pedal_printer --> oxr__oxr_core + pedal_pusher --> isaacteleop_schema + pedal_pusher --> oxr__oxr_core + pedal_pusher --> pusherio__pusherio + plugin_manager_py --> pybind11__module + plugin_manager_py --> teleop_plugin_manager + pusherio__pusherio --> oxr__oxr_utils + pusherio__pusherio --> Teleop__openxr_extensions + replay_deviceio_session_tests --> Catch2__Catch2WithMain + replay_deviceio_session_tests --> deviceio__deviceio_session + deviceio__replay_trackers --> deviceio__deviceio_trackers + deviceio__replay_trackers --> mcap__mcap_core + schema_py --> isaacteleop_schema + schema_py --> pybind11__module + schema_tests --> Catch2__Catch2WithMain + schema_tests --> OpenXR__headers + schema_tests --> isaacteleop_schema + teleop_plugin_manager --> Threads__Threads + teleop_plugin_manager --> yaml_cpp__yaml_cpp + Teleop__plugin_utils --> OpenXR__openxr_loader + Teleop__plugin_utils --> oxr__oxr_utils + Teleop__plugin_utils --> Teleop__openxr_extensions + teleop_ros2_mcap_generator --> deviceio__deviceio_session + viz__core --> glm__glm + viz__core --> OpenXR__openxr_loader + viz_core_tests --> Catch2__Catch2WithMain + viz_core_tests --> viz__test_support + viz__layers --> viz__session + viz__layers --> viz__shaders + viz__layers_testing --> viz__layers + viz_layers_tests --> Catch2__Catch2WithMain + viz_layers_tests --> viz__layers_testing + viz_layers_tests --> viz__test_support + viz_py --> pybind11__module + viz_py --> viz__layers + viz__session --> glfw + viz__session --> oxr__oxr_utils + viz__session --> viz__xr + viz_session_tests --> Catch2__Catch2WithMain + viz_session_tests --> viz__layers_testing + viz_session_tests --> viz__test_support + viz_shaders_tests --> Catch2__Catch2WithMain + viz_shaders_tests --> viz__shaders + viz__test_support --> Catch2__Catch2 + viz__test_support --> viz__core + viz__xr --> viz__core + viz_xr_tests --> Catch2__Catch2WithMain + viz_xr_tests --> viz__xr + xdev_list --> examples_common + xdev_list --> Teleop__openxr_extensions + classDef firstparty fill:#d9e8fb,stroke:#3b73b9,color:#0b2545; + classDef thirdparty fill:#ededed,stroke:#9a9a9a,color:#333333; + class Teleop__openxr_extensions,Teleop__plugin_utils,camera_plugin_oak,controller_synthetic_hands,deviceio__deviceio_base,deviceio__deviceio_py_utils,deviceio__deviceio_session,deviceio__deviceio_trackers,deviceio__live_trackers,deviceio__replay_trackers,deviceio_session_py,deviceio_trackers_py,examples_common,frame_metadata_printer,generic_3axis_pedal_plugin,isaacteleop_schema,mcap__mcap,mcap__mcap_core,mcap_tests,oxr__oxr_core,oxr__oxr_utils,oxr_py,oxr_session_sharing,oxr_simple_api_demo,pedal_printer,pedal_pusher,plugin_manager_py,pusherio__pusherio,replay_deviceio_session_tests,schema_py,schema_tests,teleop_plugin_manager,teleop_ros2_mcap_generator,viz__core,viz__layers,viz__layers_testing,viz__session,viz__shaders,viz__test_support,viz__xr,viz_core_tests,viz_layers_tests,viz_py,viz_session_tests,viz_shaders_tests,viz_xr_tests,xdev_list firstparty + class Catch2__Catch2,Catch2__Catch2WithMain,OpenXR__headers,OpenXR__openxr_loader,SDL2__SDL2_static,Threads__Threads,depthai__core,flatbuffers,glfw,glm__glm,pybind11__module,yaml_cpp__yaml_cpp thirdparty +``` + +## Layers + +| Layer | Targets | +| ----: | ------- | +| 7 | `deviceio_session_py`, `viz_layers_tests`, `viz_session_tests` | +| 6 | `deviceio::deviceio_py_utils`, `controller_synthetic_hands`, `frame_metadata_printer`, `mcap_tests`, `oxr_session_sharing`, `oxr_simple_api_demo`, `pedal_printer`, `replay_deviceio_session_tests`, `teleop_ros2_mcap_generator`, `viz::layers_testing`, `viz_py` | +| 5 | `deviceio::deviceio_session`, `viz::layers` | +| 4 | `deviceio::live_trackers`, `deviceio::replay_trackers`, `deviceio_trackers_py`, `viz::session`, `viz_xr_tests`, `viz_core_tests` | +| 3 | `deviceio::deviceio_trackers`, `camera_plugin_oak`, `generic_3axis_pedal_plugin`, `pedal_pusher`, `oxr_py`, `viz::xr`, `viz::test_support`, `xdev_list` | +| 2 | `pusherio::pusherio`, `deviceio::deviceio_base`, `oxr::oxr_core`, `mcap::mcap_core`, `schema_py`, `Teleop::plugin_utils`, `viz::core`, `examples_common`, `plugin_manager_py`, `schema_tests`, `viz_shaders_tests` | +| 1 | `oxr::oxr_utils`, `isaacteleop_schema`, `OpenXR::openxr_loader`, `teleop_plugin_manager`, `depthai::core`, `glfw`, `Catch2::Catch2WithMain` | +| 0 | `OpenXR::headers`, `flatbuffers`, `yaml-cpp::yaml-cpp`, `Threads::Threads`, `Catch2::Catch2`, `SDL2::SDL2-static`, `glm::glm`, `mcap::mcap`, `pybind11::module`, `Teleop::openxr_extensions`, `viz::shaders` | + +## Direct dependencies by target + +| Target | Type | Origin | Layer | Direct dependencies | +| ------ | ---- | ------ | ----: | ------------------- | +| `Catch2::Catch2` | Static library | third-party | 0 | _(none)_ | +| `Catch2::Catch2WithMain` | Static library | third-party | 1 | `Catch2::Catch2` (public) | +| `SDL2::SDL2-static` | Static library | third-party | 0 | _(none)_ | +| `Threads::Threads` | Interface library | third-party | 0 | _(none)_ | +| `camera_plugin_oak` | Executable | first-party | 3 | `SDL2::SDL2-static` (private), `depthai::core` (private), `isaacteleop_schema` (private), `mcap::mcap` (private), `oxr::oxr_core` (private), `pusherio::pusherio` (private) | +| `controller_synthetic_hands` | Executable | first-party | 6 | `deviceio::deviceio_session` (private), `oxr::oxr_core` (private), `Teleop::plugin_utils` (private) | +| `depthai::core` | Static library | third-party | 1 | `Threads::Threads` (private) | +| `deviceio::deviceio_base` | Interface library | first-party | 2 | `isaacteleop_schema` (interface) | +| `deviceio::deviceio_py_utils` | Interface library | first-party | 6 | `deviceio::deviceio_session` (interface) | +| `deviceio::deviceio_session` | Static library | first-party | 5 | `deviceio::live_trackers` (private), `deviceio::replay_trackers` (private) | +| `deviceio_session_py` | Module library | first-party | 7 | `deviceio::deviceio_py_utils` (private), `pybind11::module` (private) | +| `deviceio::deviceio_trackers` | Static library | first-party | 3 | `deviceio::deviceio_base` (public) | +| `deviceio_trackers_py` | Module library | first-party | 4 | `deviceio::deviceio_trackers` (private), `pybind11::module` (private) | +| `examples_common` | Static library | first-party | 2 | `OpenXR::openxr_loader` (public) | +| `flatbuffers` | Static library | third-party | 0 | _(none)_ | +| `frame_metadata_printer` | Executable | first-party | 6 | `deviceio::deviceio_session` (private), `oxr::oxr_core` (private) | +| `generic_3axis_pedal_plugin` | Executable | first-party | 3 | `isaacteleop_schema` (private), `oxr::oxr_core` (private), `pusherio::pusherio` (private) | +| `glfw` | Static library | third-party | 1 | `Threads::Threads` (private) | +| `glm::glm` | Interface library | third-party | 0 | _(none)_ | +| `OpenXR::headers` | Interface library | third-party | 0 | _(none)_ | +| `isaacteleop_schema` | Interface library | first-party | 1 | `flatbuffers` (interface) | +| `deviceio::live_trackers` | Static library | first-party | 4 | `deviceio::deviceio_trackers` (public), `mcap::mcap_core` (public), `oxr::oxr_utils` (public), `Teleop::openxr_extensions` (public) | +| `mcap::mcap_core` | Interface library | first-party | 2 | `isaacteleop_schema` (interface), `mcap::mcap` (interface) | +| `mcap::mcap` | Interface library | first-party | 0 | _(none)_ | +| `mcap_tests` | Executable | first-party | 6 | `Catch2::Catch2WithMain` (private), `deviceio::deviceio_session` (private) | +| `OpenXR::openxr_loader` | Static library | third-party | 1 | `Threads::Threads` (public), `OpenXR::headers` (public) | +| `oxr::oxr_core` | Static library | first-party | 2 | `OpenXR::openxr_loader` (public), `oxr::oxr_utils` (public) | +| `oxr_py` | Module library | first-party | 3 | `oxr::oxr_core` (private), `pybind11::module` (private) | +| `oxr_session_sharing` | Executable | first-party | 6 | `deviceio::deviceio_session` (private), `oxr::oxr_core` (private) | +| `oxr_simple_api_demo` | Executable | first-party | 6 | `deviceio::deviceio_session` (private), `oxr::oxr_core` (private) | +| `oxr::oxr_utils` | Interface library | first-party | 1 | `OpenXR::headers` (interface) | +| `pedal_printer` | Executable | first-party | 6 | `deviceio::deviceio_session` (private), `oxr::oxr_core` (private) | +| `pedal_pusher` | Executable | first-party | 3 | `isaacteleop_schema` (private), `oxr::oxr_core` (private), `pusherio::pusherio` (private) | +| `plugin_manager_py` | Module library | first-party | 2 | `pybind11::module` (private), `teleop_plugin_manager` (private) | +| `pusherio::pusherio` | Static library | first-party | 2 | `oxr::oxr_utils` (public), `Teleop::openxr_extensions` (public) | +| `pybind11::module` | Interface library | third-party | 0 | _(none)_ | +| `replay_deviceio_session_tests` | Executable | first-party | 6 | `Catch2::Catch2WithMain` (private), `deviceio::deviceio_session` (private) | +| `deviceio::replay_trackers` | Static library | first-party | 4 | `deviceio::deviceio_trackers` (public), `mcap::mcap_core` (public) | +| `schema_py` | Module library | first-party | 2 | `isaacteleop_schema` (private), `pybind11::module` (private) | +| `schema_tests` | Executable | first-party | 2 | `Catch2::Catch2WithMain` (private), `OpenXR::headers` (private), `isaacteleop_schema` (private) | +| `Teleop::openxr_extensions` | Interface library | first-party | 0 | _(none)_ | +| `teleop_plugin_manager` | Static library | first-party | 1 | `Threads::Threads` (public), `yaml-cpp::yaml-cpp` (private) | +| `Teleop::plugin_utils` | Static library | first-party | 2 | `OpenXR::openxr_loader` (public), `oxr::oxr_utils` (public), `Teleop::openxr_extensions` (public) | +| `teleop_ros2_mcap_generator` | Executable | first-party | 6 | `deviceio::deviceio_session` (private) | +| `viz::core` | Static library | first-party | 2 | `glm::glm` (public), `OpenXR::openxr_loader` (public) | +| `viz_core_tests` | Executable | first-party | 4 | `Catch2::Catch2WithMain` (private), `viz::test_support` (private) | +| `viz::layers` | Static library | first-party | 5 | `viz::session` (public), `viz::shaders` (private) | +| `viz::layers_testing` | Static library | first-party | 6 | `viz::layers` (public) | +| `viz_layers_tests` | Executable | first-party | 7 | `Catch2::Catch2WithMain` (private), `viz::layers_testing` (private), `viz::test_support` (private) | +| `viz_py` | Module library | first-party | 6 | `pybind11::module` (private), `viz::layers` (private) | +| `viz::session` | Static library | first-party | 4 | `glfw` (public), `oxr::oxr_utils` (public), `viz::xr` (public) | +| `viz_session_tests` | Executable | first-party | 7 | `Catch2::Catch2WithMain` (private), `viz::layers_testing` (private), `viz::test_support` (private) | +| `viz::shaders` | Interface library | first-party | 0 | _(none)_ | +| `viz_shaders_tests` | Executable | first-party | 2 | `Catch2::Catch2WithMain` (private), `viz::shaders` (private) | +| `viz::test_support` | Interface library | first-party | 3 | `Catch2::Catch2` (interface), `viz::core` (interface) | +| `viz::xr` | Static library | first-party | 3 | `viz::core` (public) | +| `viz_xr_tests` | Executable | first-party | 4 | `Catch2::Catch2WithMain` (private), `viz::xr` (private) | +| `xdev_list` | Executable | first-party | 3 | `examples_common` (private), `Teleop::openxr_extensions` (private) | +| `yaml-cpp::yaml-cpp` | Static library | third-party | 0 | _(none)_ | + + diff --git a/scripts/cmake_target_layers.py b/scripts/cmake_target_layers.py new file mode 100644 index 000000000..ef7483cfd --- /dev/null +++ b/scripts/cmake_target_layers.py @@ -0,0 +1,714 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Generate and verify the layered CMake target dependency diagram. + +Single source of truth for ``cmake/cmake-target-layers.md``. The diagram is derived from +*live* CMake: CMake emits its own dependency graph via ``cmake --graphviz`` (the only export +that includes INTERFACE libraries and that draws **direct** edges only, never the transitive +closure). We parse that graph, lay the targets out in topological layers -- each target sits +exactly one layer above its deepest direct dependency, so no two targets in a layer depend on +each other and a target only ever points at lower layers -- and render Markdown. + +Typical uses:: + + # CI: configure with the shared preset emits build/cmake-target-graph.dot, then: + python3 scripts/cmake_target_layers.py --dot build/cmake-target-graph.dot --check + python3 scripts/cmake_target_layers.py --dot build/cmake-target-graph.dot --out diagram.md + + # Local bootstrap from an already-configured build dir: + python3 scripts/cmake_target_layers.py --build-dir build --write + +``--check`` compares only the marker-delimited generated region, so the SPDX header (kept +current by the copyright-year hook) never registers as drift. On drift it prints a bannered +mismatch message, the unified diff, and how to refresh the committed copy, then exits +non-zero. +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DOC_RELPATH = "cmake/cmake-target-layers.md" +DEFAULT_GRAPH_NAME = "cmake-target-graph.dot" +PRESET_LABEL = "ci-linux" # the configure preset the committed diagram represents + +BEGIN_MARKER = "" +END_MARKER = "" + +# CMake graphviz node shapes -> human-readable target type (see the dot file's Legend). +SHAPE_TO_TYPE = { + "egg": "Executable", + "octagon": "Static library", + "doubleoctagon": "Shared library", + "tripleoctagon": "Module library", + "pentagon": "Interface library", + "hexagon": "Object library", + "septagon": "Unknown library", + "box": "Custom target", + "house": "Custom target", +} + +# Target types hidden from the diagram (still present in the cmake build, but not real +# CMake targets — these are raw `-l` links that CMake cannot resolve to a defined target). +# CMake's graphviz marks them with shape `septagon` ("Unknown library"). +HIDDEN_TYPES: frozenset[str] = frozenset({"Unknown library"}) + +# Individual targets hidden from the diagram. These are programmatically injected by CMake +# helper functions (e.g. pybind11_add_module) and do not represent a user-authored dependency +# choice — they are internal plumbing of third-party build machinery. +HIDDEN_TARGETS: frozenset[str] = frozenset( + { + "pybind11::lto", # LTO helper auto-injected by pybind11_add_module; not a project dep + } +) + +# CMake graphviz edge styles -> link visibility. +STYLE_TO_KIND = {"solid": "public", "dashed": "interface", "dotted": "private"} + +# Stable priority when the same (consumer -> dependency) edge appears with several styles. +KIND_PRIORITY = {"public": 0, "interface": 1, "private": 2} + +# Patterns that declare a first-party target somewhere in the repo's CMake files. +TARGET_DECL_RE = re.compile( + r"\b(?:add_library|add_executable|add_custom_target" + r"|pybind11_add_module|nanobind_add_module)\s*\(\s*([A-Za-z0-9_]+)" +) +_DECL_KEYWORDS = { + "IMPORTED", + "INTERFACE", + "STATIC", + "SHARED", + "MODULE", + "OBJECT", +} + +# Parse a node: "node12" [ label = "name\n(alias)", shape = octagon ]; +NODE_RE = re.compile( + r'"(node\d+)"\s*\[\s*label\s*=\s*"([^"]*)"\s*,\s*shape\s*=\s*(\w+)\s*\]' +) +# Parse an edge: "node3" -> "node8" [ style = dashed ] (style optional -> solid) +EDGE_RE = re.compile( + r'"(node\d+)"\s*->\s*"(node\d+)"(?:\s*\[\s*style\s*=\s*(\w+)\s*\])?' +) + + +# --------------------------------------------------------------------------- +# Repo helpers +# --------------------------------------------------------------------------- + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def first_party_targets(root: Path) -> set[str]: + """Names of targets declared in the repo's own CMake files (build/ and _deps excluded). + + Used only to colour first-party vs third-party nodes; every node is still rendered. + """ + names: set[str] = set() + for path in root.rglob("*"): + if path.name == "CMakeLists.txt" or path.suffix == ".cmake": + names |= _scan_decls(path) + return names + + +def _scan_decls(path: Path) -> set[str]: + parts = path.parts + if "build" in parts or "_deps" in parts: + return set() + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return set() + return { + m.group(1) + for m in TARGET_DECL_RE.finditer(text) + if m.group(1) not in _DECL_KEYWORDS + } + + +# --------------------------------------------------------------------------- +# Graph extraction +# --------------------------------------------------------------------------- + + +def emit_graphviz( + *, build_dir: Path | None, preset: str | None, extra: list[str] +) -> str: + """Run CMake to (re)emit the graphviz dot and return its content.""" + root = repo_root() + tmp = None + if preset: + tmp = tempfile.TemporaryDirectory(prefix="cmake-target-layers-") + dot = Path(tmp.name) / DEFAULT_GRAPH_NAME + cmd = ["cmake", "--preset", preset, *extra, f"--graphviz={dot}"] + else: + assert build_dir is not None + dot = build_dir / DEFAULT_GRAPH_NAME + # Reuse the dir's cached configuration; -B alone reconfigures with cached values. + cmd = ["cmake", "-B", str(build_dir), *extra, f"--graphviz={dot}"] + try: + proc = subprocess.run(cmd, cwd=root, capture_output=True, text=True) + if proc.returncode != 0: + sys.stderr.write(proc.stdout) + sys.stderr.write(proc.stderr) + raise SystemExit( + f"cmake failed to emit graphviz (exit {proc.returncode}): {' '.join(cmd)}" + ) + if not dot.exists(): + raise SystemExit(f"cmake did not produce {dot}") + return dot.read_text(encoding="utf-8") + finally: + if tmp is not None: + tmp.cleanup() + + +class Graph: + """Direct-dependency graph of CMake targets.""" + + def __init__( + self, + types: dict[str, str], + edges: dict[tuple[str, str], str], + node_aliases: dict[str, set[str]] | None = None, + ): + self.types = types # target name -> type label + self.edges = edges # (consumer, dependency) -> link kind + self.node_aliases = ( + node_aliases or {} + ) # canonical name -> set of CMake alias forms + self.deps: dict[str, set[str]] = {n: set() for n in types} + for consumer, dependency in edges: + self.deps[consumer].add(dependency) + + +def parse_dot(text: str) -> Graph: + id_to_name: dict[str, str] = {} + types: dict[str, str] = {} + node_aliases: dict[str, set[str]] = {} + for node_id, label, shape in NODE_RE.findall(text): + name = label.split("\\n", 1)[0].strip() + id_to_name[node_id] = name + types[name] = SHAPE_TO_TYPE.get(shape, "Unknown library") + # Labels carry CMake alias forms in parentheses, e.g. "Catch2WithMain\n(Catch2::Catch2WithMain)" + # or multiple aliases: "openxr_loader\n(OpenXR::OpenXR)\n(OpenXR::openxr_loader)". + aliases = re.findall(r"\(([^)]+)\)", label) + if aliases: + node_aliases[name] = set(aliases) + + # Drop nodes that carry no architectural information: + # - system pseudo-targets (raw -l links, shape=septagon) + # - individually denylisted build-machinery targets (e.g. pybind11::lto) + hidden = {name for name, t in types.items() if t in HIDDEN_TYPES} | HIDDEN_TARGETS + hidden &= set(types) # only drop what's actually present + for name in hidden: + del types[name] + node_aliases = {n: a for n, a in node_aliases.items() if n not in hidden} + + edges: dict[tuple[str, str], str] = {} + for src, dst, style in EDGE_RE.findall(text): + if src not in id_to_name or dst not in id_to_name: + continue + consumer, dependency = id_to_name[src], id_to_name[dst] + if consumer == dependency: + continue # drop spurious self-loops (e.g. test exes) + if consumer in hidden or dependency in hidden: + continue # drop edges to/from hidden system pseudo-targets + kind = STYLE_TO_KIND.get(style or "solid", "public") + key = (consumer, dependency) + if key not in edges or KIND_PRIORITY[kind] < KIND_PRIORITY[edges[key]]: + edges[key] = kind + return Graph(types, edges, node_aliases) + + +def _transitive_reach( + node: str, deps: dict[str, set[str]], cache: dict[str, set[str]] +) -> set[str]: + """All nodes reachable from node at distance >= 1 (node itself excluded).""" + if node in cache: + return cache[node] + result: set[str] = set() + for child in deps.get(node, set()): + result.add(child) + result |= _transitive_reach(child, deps, cache) + cache[node] = result + return result + + +def transitive_reduction(graph: Graph) -> Graph: + """Remove edges implied by transitivity, keeping only the minimal spanning set. + + Edge (u, v) is dropped when v is already reachable from u via a path of + length >= 2 through other declared dependencies. The resulting graph has + the same reachability as the original, fewer arrows, and the same layer + assignment (since redundant edges are always shorter paths). + """ + cache: dict[str, set[str]] = {} + for node in graph.types: + _transitive_reach(node, graph.deps, cache) + + redundant: set[tuple[str, str]] = set() + for u in graph.deps: + # Nodes reachable from u at distance >= 2 (via each direct dep's own reach) + indirect: set[str] = set() + for v in graph.deps[u]: + indirect |= cache[v] + for v in graph.deps[u]: + if v in indirect: + redundant.add((u, v)) + + new_edges = {k: kv for k, kv in graph.edges.items() if k not in redundant} + return Graph(graph.types, new_edges, graph.node_aliases) + + +def trim_indirect_third_party(graph: Graph, first_party: set[str]) -> Graph: + """Keep only third-party nodes that a first-party target directly links. + + Third-party packages expose a top-level API surface (e.g. ``pybind11::module``, + ``Catch2WithMain``, ``openxr_loader``). Their internal sub-targets + (``pybind11::pybind11``, ``pybind11::python_headers``, ``Catch2``, …) are + implementation details of those packages, not module-boundary information. + Hiding them keeps the diagram focused on the project's own dependency choices. + """ + directly_used = { + dep + for (consumer, dep) in graph.edges + if consumer in first_party and dep not in first_party + } + visible = (first_party | directly_used) & set(graph.types) + new_types = {n: t for n, t in graph.types.items() if n in visible} + new_edges = { + (c, d): k for (c, d), k in graph.edges.items() if c in visible and d in visible + } + new_aliases = {n: a for n, a in graph.node_aliases.items() if n in visible} + return Graph(new_types, new_edges, new_aliases) + + +def compute_layers(graph: Graph) -> dict[str, int]: + """Longest path from each node to a leaf. Guarantees layer(consumer) > layer(dependency).""" + layer: dict[str, int] = {} + + def visit(node: str, path: list[str]) -> int: + if node in layer: + return layer[node] + if node in path: + cycle = path[path.index(node) :] + [node] + raise SystemExit("dependency cycle detected: " + " -> ".join(cycle)) + path.append(node) + depth = 0 + for dep in sorted(graph.deps[node]): + depth = max(depth, 1 + visit(dep, path)) + path.pop() + layer[node] = depth + return depth + + for node in sorted(graph.types): + visit(node, []) + + # The layering invariant the whole diagram rests on. + for consumer, dependency in graph.edges: + if layer[consumer] <= layer[dependency]: + raise SystemExit( + f"layering invariant violated: {consumer} (L{layer[consumer]}) -> " + f"{dependency} (L{layer[dependency]})" + ) + return layer + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def _barycenter_order( + by_layer: dict[int, list[str]], + deps: dict[str, set[str]], + max_iter: int = 4, +) -> dict[int, list[str]]: + """Reorder nodes within each layer using the barycenter crossing-minimisation heuristic. + + Alternates downward sweeps (each layer reordered by the positions of its consumers in + the layer above) and upward sweeps (reordered by the positions of its dependencies in + the layer below). Nodes with no neighbours in the fixed adjacent layer are sorted last + in stable order so they do not displace constrained nodes. + """ + max_lyr = max(by_layer) + + # consumers[v] = nodes in higher layers that directly depend on v + consumers: dict[str, set[str]] = { + n: set() for nodes in by_layer.values() for n in nodes + } + for u, vs in deps.items(): + if u not in consumers: + continue + for v in vs: + if v in consumers: + consumers[v].add(u) + + order: dict[int, list[str]] = {lyr: list(nodes) for lyr, nodes in by_layer.items()} + + def _reorder( + lyr: int, nbr_map: dict[str, set[str]], fixed_pos: dict[str, float] + ) -> None: + def key(item: tuple[int, str]) -> tuple[float, int]: + i, node = item + nbrs = [fixed_pos[nb] for nb in nbr_map[node] if nb in fixed_pos] + return (sum(nbrs) / len(nbrs) if nbrs else float("inf"), i) + + order[lyr] = [n for _, n in sorted(enumerate(order[lyr]), key=key)] + + for _ in range(max_iter): + # Downward sweep: top → bottom, fix upper layer, reorder by consumer positions + for lyr in range(max_lyr - 1, -1, -1): + if lyr + 1 not in order: + continue + pos = {n: float(i) for i, n in enumerate(order[lyr + 1])} + _reorder(lyr, consumers, pos) + # Upward sweep: bottom → top, fix lower layer, reorder by dependency positions + for lyr in range(1, max_lyr + 1): + if lyr - 1 not in order: + continue + pos = {n: float(i) for i, n in enumerate(order[lyr - 1])} + _reorder(lyr, deps, pos) + + return order + + +def _display_name(canonical: str, aliases: set[str]) -> str: + """Return the best human-readable name for a node. + + Prefers the CMake alias over the raw graphviz name (e.g. ``OpenXR::headers`` + over ``headers``). When a node has multiple aliases, picks the one whose + suffix after ``::`` most closely matches the canonical name so that e.g. + ``OpenXR::openxr_loader`` is chosen over ``OpenXR::OpenXR``. + """ + if not aliases: + return canonical + if len(aliases) == 1: + return next(iter(aliases)) + norm = canonical.lower().replace("-", "_") + + def score(alias: str) -> tuple[int, str]: + suffix = alias.split("::")[-1].lower().replace("-", "_") + return (0 if suffix == norm else 1, alias) + + return min(aliases, key=score) + + +def _mermaid_node(node_id: str, label: str, ctype: str) -> str: + safe = label.replace('"', "#quot;") + if ctype == "Executable": + return f'{node_id}(["{safe}"])' + if ctype == "Interface library": + return f'{node_id}{{{{"{safe}"}}}}' + if ctype == "Module library": + return f'{node_id}[["{safe}"]]' + if ctype == "Object library": + return f'{node_id}[/"{safe}"/]' + return f'{node_id}["{safe}"]' # static/shared/unknown libs, custom targets + + +def render_generated_block( + graph: Graph, layer: dict[str, int], first_party: set[str] +) -> str: + names = sorted(graph.types) + disp = { + name: _display_name(name, graph.node_aliases.get(name, set())) for name in names + } + # Use the sanitized display name as the node ID so that adding or removing + # a target only touches lines that reference that target, not every edge. + raw_ids = [re.sub(r"[^A-Za-z0-9_]", "_", disp[name]) for name in names] + if len(set(raw_ids)) != len(raw_ids): + raise SystemExit("sanitized Mermaid node IDs collide; check target names") + node_id = dict(zip(names, raw_ids)) + max_layer = max(layer.values()) if layer else 0 + n_edges = len(graph.edges) + by_layer: dict[int, list[str]] = {} + for n in names: + by_layer.setdefault(layer[n], []).append(n) + by_layer = _barycenter_order(by_layer, graph.deps) + + lines: list[str] = [BEGIN_MARKER, ""] + lines.extend( + [ + "## Overview", + "", + f"- **{len(names)}** targets, **{n_edges}** direct dependencies, **{max_layer + 1}** layers.", + f"- Generated from configure preset `{PRESET_LABEL}` (see `CMakePresets.json`).", + "- Layer *k* contains targets whose deepest direct-dependency chain is *k* long; " + "every dependency points to a strictly lower layer, so there are **no edges within a " + "layer**. This is a layered DAG (shared foundations create diamonds), not a strict tree.", + "- Raw system library links (`-ldl`, `-lstdc++fs`, …) are omitted: CMake records " + "them as *Unknown library* nodes (not real CMake targets) and they carry no " + "structural information about module boundaries.", + "- Third-party nodes that no first-party target links directly are omitted (e.g. " + "`pybind11::pybind11`, `Catch2`, `ProjectConfig`). Only the top-level API surface " + "that this project actually links against is shown; internal sub-targets of " + "third-party packages are implementation details of those packages. " + "A small set of individually-named build-machinery targets (see `HIDDEN_TARGETS` in " + "the generator script) are also omitted — these are programmatically injected by " + "CMake helper functions and do not represent user-authored dependency choices.", + "- **Transitive reduction applied:** edges that are already implied by a longer " + "dependency path are omitted (e.g. if A → B → C, the redundant A → C edge is " + "dropped). The graph has the same reachability as the raw CMake declarations; " + "see the `CMakeLists.txt` files for every declared `target_link_libraries` call.", + "", + "### Legend", + "", + "- Node shape: `([executable])`, `[static / shared library]`, " + "`[[module library]]`, `{{interface library}}`, `[/object library/]`, " + "`[custom target]`.", + "- Node colour: blue = first-party target, grey = third-party dependency.", + "- Arrow `A --> B` means **A depends on B** (B is in a lower layer).", + "- Link visibility (`public` / `private` / `interface`) is listed in the " + "per-target table below.", + "", + ] + ) + + layer_rows = [ + (lyr, by_layer[lyr]) for lyr in range(max_layer, -1, -1) if lyr in by_layer + ] + + # --- Mermaid diagram ----------------------------------------------------- + lines.append("## Layered dependency graph") + lines.append("") + lines.append("```mermaid") + lines.append( + '%%{init: {"flowchart": {"rankSpacing": 120}, "themeVariables": {"fontSize": "24pt"}} }%%' + ) + lines.append("flowchart TD") + for lyr, members in layer_rows: + if lyr == max_layer: + title = f"Layer {lyr} - top (consumers)" + elif lyr == 0: + title = f"Layer {lyr} - foundation" + else: + title = f"Layer {lyr}" + lines.append(f' subgraph LYR{lyr}["{title}"]') + for name in members: + lines.append( + " " + _mermaid_node(node_id[name], disp[name], graph.types[name]) + ) + lines.append(" end") + # Edges, sorted for determinism. + for consumer, dependency in sorted(graph.edges): + lines.append(f" {node_id[consumer]} --> {node_id[dependency]}") + # Styling. + fp = sorted(node_id[n] for n in names if n in first_party) + tp = sorted(node_id[n] for n in names if n not in first_party) + lines.append(" classDef firstparty fill:#d9e8fb,stroke:#3b73b9,color:#0b2545;") + lines.append(" classDef thirdparty fill:#ededed,stroke:#9a9a9a,color:#333333;") + if fp: + lines.append(" class " + ",".join(fp) + " firstparty") + if tp: + lines.append(" class " + ",".join(tp) + " thirdparty") + lines.append("```") + lines.append("") + + # --- Layer roster -------------------------------------------------------- + lines.append("## Layers") + lines.append("") + lines.append("| Layer | Targets |") + lines.append("| ----: | ------- |") + for lyr, members in layer_rows: + lines.append(f"| {lyr} | {', '.join(f'`{disp[m]}`' for m in members)} |") + lines.append("") + + # --- Per-target direct dependencies (the precise diff surface) ----------- + lines.append("## Direct dependencies by target") + lines.append("") + lines.append("| Target | Type | Origin | Layer | Direct dependencies |") + lines.append("| ------ | ---- | ------ | ----: | ------------------- |") + for name in names: + origin = "first-party" if name in first_party else "third-party" + deps = sorted(graph.deps[name]) + if deps: + dep_text = ", ".join( + f"`{disp[d]}` ({graph.edges[(name, d)]})" for d in deps + ) + else: + dep_text = "_(none)_" + lines.append( + f"| `{disp[name]}` | {graph.types[name]} | {origin} | {layer[name]} | {dep_text} |" + ) + + lines.append("") + lines.append(END_MARKER) + return "\n".join(lines) + + +def build_full_document(block: str) -> str: + year = datetime.now().year + preamble = [ + f"", + "", + "", + "# CMake target dependency layers", + "", + "Layered, **direct-dependency** view of this project's CMake targets, derived from live", + "CMake (`cmake --graphviz`). Targets are sorted into topological layers: a target sits one", + "layer above its deepest direct dependency, so no two targets in a layer depend on each", + "other and dependencies always point to lower layers.", + "", + "> **Auto-generated -- do not edit the region between the markers below.**", + "> Regenerate by replacing this file with the `cmake-target-layers` artifact from the", + "> *Verify CMake target layers* CI run, or locally with the full CI toolchain via", + "> `python3 scripts/cmake_target_layers.py --preset ci-linux --write`.", + "> CI fails when the committed diagram drifts from what CMake reports.", + "", + ] + return "\n".join(preamble) + "\n" + block + "\n" + + +def extract_block(text: str) -> str | None: + start = text.find(BEGIN_MARKER) + end = text.find(END_MARKER) + if start == -1 or end == -1 or end < start: + return None + return text[start : end + len(END_MARKER)] + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def load_graph(args: argparse.Namespace) -> Graph: + """Parse the graphviz dot (emitting it via cmake if needed). Returns the raw graph.""" + if args.dot: + dot_path = Path(args.dot) + if not dot_path.exists(): + raise SystemExit(f"--dot path does not exist: {dot_path}") + dot_text = dot_path.read_text(encoding="utf-8") + else: + build_dir = Path(args.build_dir).resolve() if args.build_dir else None + dot_text = emit_graphviz( + build_dir=build_dir, preset=args.preset, extra=args.cmake_arg + ) + graph = parse_dot(dot_text) + if not graph.types: + raise SystemExit("no targets parsed from dot input") + return graph + + +def _write_document(path: Path, document: str, graph: Graph) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(document, encoding="utf-8") + print(f"wrote {path} ({len(graph.types)} targets, {len(graph.edges)} edges)") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + src = parser.add_mutually_exclusive_group(required=True) + src.add_argument( + "--dot", help="read an existing graphviz dot emitted by cmake --graphviz" + ) + src.add_argument( + "--build-dir", help="reuse an already-configured build dir to emit the graph" + ) + src.add_argument( + "--preset", help="configure with this CMake preset to emit the graph" + ) + parser.add_argument( + "--cmake-arg", + action="append", + default=[], + help="extra arg passed to cmake (repeatable), e.g. --cmake-arg=-DENABLE_X=OFF", + ) + out = parser.add_mutually_exclusive_group(required=True) + out.add_argument( + "--check", action="store_true", help="fail if the committed diagram is stale" + ) + out.add_argument("--write", action="store_true", help=f"write {DOC_RELPATH}") + out.add_argument( + "--out", help="write the full document to this path (e.g. for a CI artifact)" + ) + args = parser.parse_args(argv) + + first_party = first_party_targets(repo_root()) + graph = transitive_reduction( + trim_indirect_third_party(load_graph(args), first_party) + ) + layer = compute_layers(graph) + block = render_generated_block(graph, layer, first_party) + doc_path = repo_root() / DOC_RELPATH + + if args.write or args.out: + _write_document( + Path(args.out) if args.out else doc_path, build_full_document(block), graph + ) + return 0 + + # --check + if not doc_path.exists(): + print( + f"ERROR: {DOC_RELPATH} is missing; run the generator to create it.", + file=sys.stderr, + ) + return 1 + committed = extract_block(doc_path.read_text(encoding="utf-8")) + if committed is None: + print( + f"ERROR: {DOC_RELPATH} has no generated region markers; regenerate it.", + file=sys.stderr, + ) + return 1 + if committed == block: + print(f"OK: {DOC_RELPATH} is up to date ({len(graph.types)} targets).") + return 0 + + import difflib + + diff = difflib.unified_diff( + committed.splitlines(), + block.splitlines(), + fromfile=f"{DOC_RELPATH} (committed)", + tofile="live cmake graph", + lineterm="", + ) + message = [ + f"ERROR: {DOC_RELPATH} is out of date with live CMake.", + "", + "Refresh it by downloading the `cmake-target-layers` artifact from the " + "'Verify CMake target layers' CI run and committing it", + "(or run `python3 scripts/cmake_target_layers.py --preset ci-linux --write` " + "with the full CI toolchain).", + ] + banner = "=" * 78 + if os.environ.get("GITHUB_ACTIONS") == "true": + print( + f"::error::{DOC_RELPATH} is out of date with live CMake. Refresh it from " + "the `cmake-target-layers` artifact or run the generator locally." + ) + print( + "\n".join( + [ + "", + banner, + *message, + banner, + "", + "BEGIN GENERATED REGION DIFF", + *diff, + "END GENERATED REGION DIFF", + ] + ) + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())