From a88102a3c03f4d1abddf9c2795eb0aa68af7fbf5 Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Sun, 27 Sep 2026 08:09:25 +0200 Subject: [PATCH 1/3] config/diff: toolchain_includes -- verify wrapper-injected include roots Toolchains that wrap the compiler add include roots at execution time: a cross SDK's gcc wrapper adds `-idirafter /usr/include` when it runs, the sysroot's builtin dirs are always searched, and ccache/distcc/vendor-SDK shims do the same kind of thing. The reference build often spells those roots out on every TU (CMAKE_C_FLAGS), `bazel aquery` can never show them, and until now the only lever was ignore.include_prefixes, which deletes the root from BOTH sides -- so the reference silently losing the root is never noticed, and every root beneath the prefix (a /usr/include/json-c dependency root) stops being verified as well. `toolchain_includes` is the asymmetric lever: each declared root is REQUIRED on every CMake TU (its absence is an includes_diff error -- the assertion is stale or the reference regressed) and counts as present on the Bazel side. It matches by directory identity, not by prefix, on purpose: a subdirectory of a toolchain root is a different search directory with its own meaning and is compared on its own merits (include_map it if Bazel spells it differently). A prefix match would swallow it exactly the way include_prefixes does; on a real cross-compiled project a prefix on the toolchain dir hid that /include/fortify (where the reference resolves to a fortify wrapper) was absent on the Bazel side. The toolchain root is decided first in _norm_includes, so neither include_map nor include_prefixes can rewrite or delete it out of the required check; both still govern every other root. triage.py now prints each kind's distinct `detail` lines, because the new finding (root under bazel_only, severity error) would otherwise sit under the "usually tolerated" label with nothing saying how to read it. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/config.py | 60 ++++++++++++++++++++- scripts/diff.py | 60 +++++++++++++++++---- scripts/triage.py | 9 ++++ tests/test_engine.py | 123 +++++++++++++++++++++++++++++++++++++++++++ tests/test_triage.py | 24 +++++++++ 5 files changed, 264 insertions(+), 12 deletions(-) diff --git a/scripts/config.py b/scripts/config.py index fd17cbd..dcf9289 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -28,12 +28,19 @@ Applied at DIFF time and to BOTH sides, so tuning them and re-diffing needs no re-extraction. + * toolchain_includes : include roots the BAZEL TOOLCHAIN supplies without + them appearing in aquery's argv (a compiler wrapper's runtime + -idirafter, the sysroot's builtin dirs). Not a suppression: + the reference is REQUIRED to carry each one, and only the + Bazel side is excused. See MigrationConfig.toolchain_includes. + Because it's a file, every suppression is an explicit, reviewable, version- controlled line -- a durable record of why a given difference was accepted. Example any2bazel.json: { "target_map": { }, + "toolchain_includes": ["/opt/sdk/staging/usr/include"], "ignore": { "defines": ["BORINGSSL_DISPATCH_TEST"], "flags": ["-Wctad-maybe-unsupported", "-fvisibility=hidden"], @@ -46,8 +53,9 @@ import json import os +import posixpath from dataclasses import dataclass, field -from typing import Dict, List, Set +from typing import Dict, List, Optional, Set CONFIG_FILENAME = "any2bazel.json" @@ -81,6 +89,30 @@ class MigrationConfig: # 'external/absl+' and 'bazel-out/.../external/absl+' twin both -> '@absl'). # Longest from-prefix wins. Applied before ignore_include_prefixes. include_map: tuple = () # tuple of (from_prefix, to_token) + # Include roots the BAZEL TOOLCHAIN provides IMPLICITLY -- a compiler + # wrapper that adds `-idirafter /usr/include` when it runs, the + # sysroot's builtin dirs (cxx_builtin_include_directories) -- which the + # reference build spells out explicitly on every TU (e.g. in + # CMAKE_C_FLAGS) but which `bazel aquery` can never show, because they are + # added at EXECUTION time. The asymmetric lever for that: + # * CMake side: each root is REQUIRED on every TU. Its absence is an + # includes_diff error (the config asserts a fact about the reference; + # if the reference stops carrying the root the assertion is stale, or + # the reference regressed, and either way someone must look). + # * Bazel side: the root counts as present; it is dropped from the + # comparison instead of being demanded of the argv. + # This is the opposite of ignore_include_prefixes, which deletes the root + # from BOTH sides and so never notices the reference losing it. + # Matching is by DIRECTORY IDENTITY (normalized path equality), not by + # prefix: a root beneath a toolchain root -- `/usr/include/json-c` + # under `/usr/include` -- is a different search directory with its + # own meaning and is compared on its own merits (mapped by include_map if + # spelled differently per side, else required verbatim). A prefix match + # would swallow it exactly the way include_prefixes does. Populate from the + # toolchain, not from memory: scripts/probe_toolchain.py prints the + # search list the (wrapped) compiler actually uses. A plain host build + # whose reference passes no such roots needs none. + toolchain_includes: tuple = () # Source-path PREFIX REWRITES for TRANSLATION-UNIT keys, applied to both # sides before the TU-set comparison -- the compile-source analogue of # include_map. Same (from_prefix, to_token) shape, longest-prefix-wins. @@ -112,6 +144,12 @@ class MigrationConfig: # test-binary existence/count check. See PARTICIPATING_ROLES in diff.py. include_tests: bool = False + def __post_init__(self): + # Normalize the declared toolchain roots once, so the identity test + # in toolchain_root() is plain equality against canonical spellings. + self.toolchain_includes = tuple( + r for r in (_norm_root(t) for t in self.toolchain_includes) if r) + def flag_ignored(self, flag: str) -> bool: return (flag in self.ignore_flags or any(flag.startswith(p) for p in self.ignore_flag_prefixes)) @@ -157,10 +195,29 @@ def map_source(self, source: str) -> str: def include_ignored(self, include: str) -> bool: return any(include.startswith(p) for p in self.ignore_include_prefixes) + def toolchain_root(self, include: str) -> Optional[str]: + """The declared toolchain root that `include` IS (the same directory, + after path normalization), or None. Deliberately not a prefix match -- + see the toolchain_includes field: a subdirectory of a toolchain root is + its own search directory and must be compared, not excused.""" + norm = _norm_root(include) + return norm if norm in self.toolchain_includes else None + def target_excluded(self, name: str) -> bool: return name in self.exclude_targets +def _norm_root(path: str) -> str: + """Canonical spelling of an include directory for identity comparison: + POSIX separators, `.`/`..` segments collapsed, no trailing slash. The same + normalization canonicalize.py applies to the models' include paths, so a + config entry and a model root that name one directory compare equal.""" + p = (path or "").strip().replace(os.sep, "/") + if not p: + return "" + return posixpath.normpath(p) + + def load(path: str) -> MigrationConfig: """Load config from an explicit file path, or return empty if absent.""" if not path or not os.path.isfile(path): @@ -181,6 +238,7 @@ def load(path: str) -> MigrationConfig: (e["from"], e["to"]) for e in ig.get("include_map", [])), source_map=tuple( (e["from"], e["to"]) for e in ig.get("source_map", [])), + toolchain_includes=tuple(obj.get("toolchain_includes", []) or ()), exclude_targets=set(obj.get("exclude_targets", [])), bazel_args=tuple(obj.get("bazel_args", [])), include_tests=bool(obj.get("include_tests", False)), diff --git a/scripts/diff.py b/scripts/diff.py index d602dcb..270ff38 100644 --- a/scripts/diff.py +++ b/scripts/diff.py @@ -34,7 +34,7 @@ import json from dataclasses import asdict, dataclass from enum import Enum -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from canonicalize import BAZEL_TOLERATED_FLAG_PREFIXES from config import MigrationConfig @@ -110,12 +110,17 @@ def _diff_tu(target: str, a: TranslationUnit, b: TranslationUnit, )) # includes: PRESENCE check (order currently NOT enforced -- see below). - # Two normalizations, applied to both sides first: - # 1. include_map: rewrite differing spellings of the same dep root to a + # Three normalizations, applied to both sides first: + # 1. toolchain_includes: a root the Bazel toolchain supplies at execution + # time (wrapper -idirafter, sysroot builtin dir) is set aside on both + # sides -- excused from the Bazel argv, but REQUIRED on the CMake side + # (checked below). Decided first, on the exact directory, so neither + # lever below can rewrite or delete it out from under the check. + # 2. include_map: rewrite differing spellings of the same dep root to a # canonical token, so the check is PRESERVED (the dep must still be # present). Collapse adjacent dups the rewrite produces (Bazel's # external/X and bazel-out/.../external/X both -> the token). - # 2. include_ignored: drop blind-spot prefixes entirely. + # 3. include_ignored: drop blind-spot prefixes entirely. # # We require every CMake include root to be PRESENT on the Bazel side, but # do NOT (yet) enforce relative ORDER. Order only changes the build when the @@ -124,8 +129,8 @@ def _diff_tu(target: str, a: TranslationUnit, b: TranslationUnit, # verifier exists, enforcing order produced benign false positives (e.g. # boringssl: project `include` vs vendored gtest roots, disjoint headers, # reordered). See docs/FUTURE-include-order-collision-check.md. - a_inc = _norm_includes(a.includes, cfg) - b_inc = _norm_includes(b.includes, cfg) + a_inc, a_tool = _norm_includes(a.includes, cfg) + b_inc, _ = _norm_includes(b.includes, cfg) missing = [i for i in a_inc if i not in set(b_inc)] if missing: out.append(Discrepancy( @@ -136,6 +141,23 @@ def _diff_tu(target: str, a: TranslationUnit, b: TranslationUnit, cmake_only=missing, bazel_only=[i for i in b_inc if i not in set(a_inc)], )) + # toolchain_includes is an ASSERTION about the reference: every declared + # root must be on this CMake TU. One that is not means the config is stale + # or the reference lost the root -- and the Bazel side, which was excused + # from it above, now carries a search directory the reference does not. + # Reported under bazel_only (implicitly present there, absent in CMake); + # ignore.include_prefixes would have deleted it from both sides silently. + absent = [t for t in cfg.toolchain_includes if t not in a_tool] + if absent: + out.append(Discrepancy( + kind=Kind.INCLUDES_DIFF.value, + severity=Severity.ERROR.value, + target=target, tu=a.source, + detail="toolchain include root (toolchain_includes) not on the " + "cmake side: stale config, or the reference lost it", + cmake_only=[], + bazel_only=absent, + )) # other flags: asymmetric subset -- A must be subset of B. # Reviewer-approved ignores are dropped from both sides first. @@ -173,18 +195,34 @@ def _diff_link_flags(name: str, ta: "TargetView", tb: "TargetView", return [] -def _norm_includes(includes, cfg) -> tuple: - """Apply include_map rewrites then drop ignored prefixes, collapsing the - consecutive duplicates a rewrite can create (e.g. external/X and its - bazel-out twin both map to the same token). Order preserved.""" +def _norm_includes(includes, cfg) -> Tuple[tuple, frozenset]: + """Normalize one side's include roots for comparison. Returns + (roots, toolchain_roots): + + roots -- include_map rewrites applied, then ignored prefixes + dropped, consecutive duplicates a rewrite can create + collapsed (external/X and its bazel-out twin both map + to one token). Order preserved. + toolchain_roots -- the cfg.toolchain_includes entries this TU carries + explicitly (normalized spelling). Set aside BEFORE + the map/ignore steps, on directory identity, so a + toolchain root can neither be rewritten to a token + nor deleted by a prefix; _diff_tu requires each + declared root in the CMake side's set. + """ out = [] + tool = set() for inc in includes: + root = cfg.toolchain_root(inc) + if root is not None: + tool.add(root) + continue mapped = cfg.map_include(inc) if cfg.include_ignored(mapped) or cfg.include_ignored(inc): continue if not out or out[-1] != mapped: out.append(mapped) - return tuple(out) + return tuple(out), frozenset(tool) # Roles the parity diff actually compares. Everything else (dashboard, diff --git a/scripts/triage.py b/scripts/triage.py index 78729fd..beb9bdd 100644 --- a/scripts/triage.py +++ b/scripts/triage.py @@ -67,10 +67,16 @@ def triage(diff: dict, only_kind: Optional[str] = None) -> dict: continue cmake_only = _histogram(discs, k, "cmake_only") bazel_only = _histogram(discs, k, "bazel_only") + # One kind can carry differently-read findings (includes_diff: a + # root missing on the Bazel side, OR a toolchain_includes root the + # reference no longer carries -- reported under bazel_only). The + # detail says which; surface it so the histogram is read correctly. + details = Counter(d.get("detail", "") for d in discs if d.get("kind") == k) out_kinds.append({ "kind": k, "severity": by_kind_sev[k], "count": by_kind[k], + "details": details.most_common(), # most_common(): systematic causes float to the top "cmake_only": cmake_only.most_common(), "bazel_only": bazel_only.most_common(), @@ -97,6 +103,9 @@ def render(t: dict, top: int = 25) -> str: lines.append(f"{status} errors={t['errors']} warnings={t['warnings']}") for k in t["kinds"]: lines.append(f"\n[{k['severity']}] {k['kind']} x{k['count']}") + for detail, n in k.get("details", []): + if detail: + lines.append(f" {n:5d} {detail}") for label, hist, cap in ( ("cmake_only (add on bazel / accept in config)", k["cmake_only"], None), ("bazel_only (usually tolerated)", k["bazel_only"], top)): diff --git a/tests/test_engine.py b/tests/test_engine.py index c8a83ea..b48d23d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -522,6 +522,126 @@ def test_include_map_collapses_bazel_duplicate_roots(): assert summarize(diff_models(a, b, cfg))["converged"] +def _tc_models(cmake_inc, bazel_inc): + """CMake/Bazel single-TU models differing only in include roots.""" + a = _model(tu_from_raw("src/a.cpp", ["-DFOO=1"] + cmake_inc, is_bazel=False)) + b = _model(tu_from_raw("src/a.cpp", ["-DFOO=1"] + bazel_inc, is_bazel=True), + is_bazel=True) + return a, b + + +def test_toolchain_include_implicit_on_bazel_required_on_cmake(): + # A wrapped cross toolchain: the reference spells the wrapper's root on + # every TU (-I/usr/include via CMAKE_C_FLAGS); Bazel's aquery argv + # never has it, because the wrapper adds -idirafter at execution time. + # Declared as toolchain-provided, the root is excused on the Bazel side. + a, b = _tc_models(["-I", "/sdk/staging/usr/include", "-I", "/work/proj/include"], + ["-I", "/work/proj/include"]) + assert not summarize(diff_models(a, b))["converged"] # unaided: looks like a gap + cfg = MigrationConfig(toolchain_includes=("/sdk/staging/usr/include",)) + assert summarize(diff_models(a, b, cfg))["converged"] + + +def test_toolchain_include_absent_on_cmake_is_an_error(): + # THE difference from include_prefixes: the config asserts the reference + # carries the root, so the reference LOSING it (or a stale assertion) is + # caught. include_prefixes deletes the root from both sides and never + # notices. + a, b = _tc_models(["-I", "/work/proj/include"], ["-I", "/work/proj/include"]) + cfg = MigrationConfig(toolchain_includes=("/sdk/staging/usr/include",)) + discs = diff_models(a, b, cfg) + d = next(x for x in discs if x.kind == "includes_diff") + assert d.severity == "error", d + assert d.bazel_only == ["/sdk/staging/usr/include"] and d.cmake_only == [], d + assert "toolchain_includes" in d.detail + # the blind spot, for contrast + cfg = MigrationConfig(ignore_include_prefixes=("/sdk/staging/usr/include",)) + assert summarize(diff_models(a, b, cfg))["converged"] + + +def test_toolchain_include_does_not_swallow_a_root_beneath_it(): + # /usr/include/json-c is its own search directory: the toolchain + # root above it excuses only itself (directory identity, not prefix), so + # an unmapped json-c root absent on the Bazel side IS reported -- + # include_prefixes on the parent would have deleted it unseen. + a, b = _tc_models(["-I", "/sdk/staging/usr/include", + "-I", "/sdk/staging/usr/include/json-c"], []) + cfg = MigrationConfig(toolchain_includes=("/sdk/staging/usr/include",)) + discs = diff_models(a, b, cfg) + d = next(x for x in discs if x.kind == "includes_diff") + assert d.cmake_only == ["/sdk/staging/usr/include/json-c"], d + assert len([x for x in discs if x.kind == "includes_diff"]) == 1, discs + cfg = MigrationConfig(ignore_include_prefixes=("/sdk/staging/usr/include",)) + assert summarize(diff_models(a, b, cfg))["converged"] # the blind spot + + +def test_toolchain_include_composes_with_include_map(): + # The json-c root spelled differently per side: an include_map entry + # claims it (longer than the toolchain root, so it is its own directory + # either way), rewrites both spellings to one token and verifies + # presence; the toolchain root still governs itself. + a, b = _tc_models(["-I", "/sdk/staging/usr/include", + "-I", "/sdk/staging/usr/include/json-c"], + ["-I", "/work/proj/external/staging/include/json-c"]) + cfg = MigrationConfig( + toolchain_includes=("/sdk/staging/usr/include",), + include_map=(("/sdk/staging/usr/include/json-c", "@json-c"), + ("external/staging/include/json-c", "@json-c"))) + assert summarize(diff_models(a, b, cfg))["converged"] + # ... and the map still catches json-c genuinely absent on the Bazel side + a2, b2 = _tc_models(["-I", "/sdk/staging/usr/include", + "-I", "/sdk/staging/usr/include/json-c"], []) + discs = diff_models(a2, b2, cfg) + assert [x.cmake_only for x in discs if x.kind == "includes_diff"] == [["@json-c"]], discs + # A map whose from-prefix is SHORTER than the toolchain root cannot + # rewrite the toolchain root out of the required-on-cmake check (the + # toolchain root is decided first, on the exact directory) ... + cfg = MigrationConfig(toolchain_includes=("/sdk/staging/usr/include",), + include_map=(("/sdk/staging", "@staging"),)) + a3, b3 = _tc_models(["-I", "/work/proj/include"], ["-I", "/work/proj/include"]) + assert any(x.kind == "includes_diff" and x.bazel_only == ["/sdk/staging/usr/include"] + for x in diff_models(a3, b3, cfg)) + # ... nor out of being excused on the Bazel side + a4, b4 = _tc_models(["-I", "/sdk/staging/usr/include"], []) + assert summarize(diff_models(a4, b4, cfg))["converged"] + + +def test_toolchain_include_composes_with_include_prefixes(): + # Both levers at once: the toolchain root is decided first, so a prefix + # ignore that also matches it neither deletes it from the required check + # nor stops it excusing the Bazel side; the prefix still deletes the + # OTHER roots under it (here an empty sysroot dir with nothing to verify). + cfg = MigrationConfig(toolchain_includes=("/sdk/staging/usr/include",), + ignore_include_prefixes=("/sdk/",)) + a, b = _tc_models(["-I", "/sdk/staging/usr/include", + "-I", "/sdk/toolchain/usr/include"], []) + assert summarize(diff_models(a, b, cfg))["converged"] + a2, b2 = _tc_models(["-I", "/sdk/toolchain/usr/include"], []) + discs = diff_models(a2, b2, cfg) + assert [x.bazel_only for x in discs if x.kind == "includes_diff"] == \ + [["/sdk/staging/usr/include"]], discs + + +def test_toolchain_include_matches_by_directory_identity(): + # Spelling differences that name the same directory (trailing slash, `..` + # segments) match; a Bazel toolchain that ALSO passes the root explicitly + # (a feature flag_set) is fine; a sibling that merely shares the prefix + # string is not the same directory. + cfg = MigrationConfig(toolchain_includes=("/sdk/staging/usr/include/",)) + assert cfg.toolchain_includes == ("/sdk/staging/usr/include",) + a, b = _tc_models(["-I", "/sdk/staging/usr/lib/../include/"], []) + assert summarize(diff_models(a, b, cfg))["converged"] + a, b = _tc_models(["-I", "/sdk/staging/usr/include"], + ["-isystem", "/sdk/staging/usr/include"]) + assert summarize(diff_models(a, b, cfg))["converged"] + a, b = _tc_models(["-I", "/sdk/staging/usr/include-fixed"], []) + discs = diff_models(a, b, cfg) + assert [x.cmake_only for x in discs if x.cmake_only] == \ + [["/sdk/staging/usr/include-fixed"]], discs # not excused ... + assert [x.bazel_only for x in discs if x.bazel_only] == \ + [["/sdk/staging/usr/include"]], discs # ... and the real root is missing + + def test_exclude_targets_suppresses_structural_diffs(): # A whole target present only in cmake (e.g. vendored third-party) produces # missing_tu/missing_target unless excluded. exclude_targets is the only @@ -568,6 +688,7 @@ def test_config_loads_all_fields_from_json(): "include_prefixes": ["third_party/"], "include_map": [{"from": "external/absl+", "to": "@absl"}], }, + "toolchain_includes": ["/sdk/staging/usr/include/"], } with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: json.dump(obj, f) @@ -585,6 +706,8 @@ def test_config_loads_all_fields_from_json(): assert cfg.link_flag_ignored("-Wl,--gc-sections") # prefix match assert cfg.include_ignored("third_party/gtest/include") assert cfg.map_include("external/absl+/base") == "@absl/base" # rewrite + assert cfg.toolchain_root("/sdk/staging/usr/include") == "/sdk/staging/usr/include" + assert cfg.toolchain_root("/sdk/staging/usr/include/json-c") is None # identity, not prefix def _test_models(cmake_test_srcs, bazel_test_srcs, n_cmake_bins=1, n_bazel_bins=1): diff --git a/tests/test_triage.py b/tests/test_triage.py index cb36bcb..668898e 100644 --- a/tests/test_triage.py +++ b/tests/test_triage.py @@ -46,6 +46,30 @@ def test_groups_by_kind_with_histogram(): assert kinds["missing_dep"]["cmake_only"][0] == ("z", 1) +def test_details_surfaced_per_kind(): + # the detail line tells the reader HOW to read a kind's histogram: an + # includes_diff for a toolchain_includes root the reference lost lives + # under bazel_only, and only its detail says so + diff = {"converged": False, "errors": 2, "warnings": 0, "discrepancies": [ + {"kind": "includes_diff", "severity": "error", "target": "", + "tu": "a.c", "detail": "cmake include root missing on bazel side", + "cmake_only": ["/sdk/include/fortify"], "bazel_only": []}, + {"kind": "includes_diff", "severity": "error", "target": "", + "tu": "b.c", "detail": "toolchain include root (toolchain_includes) not on " + "the cmake side: stale config, or the reference lost it", + "cmake_only": [], "bazel_only": ["/sdk/staging/usr/include"]}, + ]} + t = triage(diff) + k = t["kinds"][0] + assert dict(k["details"]) == { + "cmake include root missing on bazel side": 1, + "toolchain include root (toolchain_includes) not on the cmake side: " + "stale config, or the reference lost it": 1} + text = render(t) + assert "toolchain include root (toolchain_includes)" in text + assert "1 /sdk/staging/usr/include" in text + + def test_kinds_sorted_by_frequency(): t = triage(DIFF) counts = [k["count"] for k in t["kinds"]] From bd6d7aae5334ed4fc17e3c0f3707602287c91899 Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Sun, 27 Sep 2026 08:09:41 +0200 Subject: [PATCH 2/3] Add probe_toolchain.py: enumerate what a compiler wrapper injects toolchain_includes entries must be facts about the toolchain, not recall. The probe runs the very compiler command the Bazel toolchain runs, with the env it runs under, and reports what the driver actually does: `-v -E -x /dev/null` for the effective "..." / <...> search lists, the cc1 command with every include flag the driver injected and the driver-level options a wrapper prepended; `-### ... -o /dev/null` for the -L / -rpath-link / --sysroot the link command gets. Nothing is compiled or linked. It ends with a toolchain_includes snippet to paste (`--json` for the raw report). Running it with and without a wrapper's gating environment variable shows exactly what that variable adds. Checked against the host gcc and a wrapped cross gcc whose driver adds `-idirafter /usr/include` only when its environment variable is set. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/probe_toolchain.py | 364 ++++++++++++++++++++++++++++++++++ tests/test_probe_toolchain.py | 233 ++++++++++++++++++++++ 2 files changed, 597 insertions(+) create mode 100644 scripts/probe_toolchain.py create mode 100644 tests/test_probe_toolchain.py diff --git a/scripts/probe_toolchain.py b/scripts/probe_toolchain.py new file mode 100644 index 0000000..9a8a5cd --- /dev/null +++ b/scripts/probe_toolchain.py @@ -0,0 +1,364 @@ +# Copyright 2026 EngFlow Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Ask the compiler what it injects, so `toolchain_includes` is fact, not recall. + +`bazel aquery` shows the argv Bazel HANDS to the compiler. Anything the +compiler adds after that -- a wrapper script's `--sysroot`, a patched driver's +`-idirafter $STAGING_DIR/usr/include` (OpenWrt), the sysroot's builtin search +dirs, ccache/distcc/vendor-SDK shims -- is invisible to the diff and shows up +as an `includes_diff` on every TU, because the reference build (CMake's +CMAKE_C_FLAGS) spells those roots out. The any2bazel.json lever for that is +`toolchain_includes`, and its entries must be true: this probe runs the very +compiler command the Bazel toolchain runs, with the env it runs under, and +prints what the driver actually does: + + * `-v -E -x /dev/null` -> the effective `#include "..."` / `<...>` + search lists, the cc1 command with every + include flag the driver injected, the + driver-level options (COLLECT_GCC_OPTIONS) + a wrapper prepended + * `-### -x /dev/null -o` -> the link command: `-L` dirs, `-rpath-link`, + `--sysroot` the driver adds + +and ends with the JSON snippet to paste. Nothing is compiled or linked +(`-E` to nowhere; `-###` prints without executing). + +Usage: + python3 scripts/probe_toolchain.py [--env KEY=VAL]... [--lang c|c++] \ + [--json] -- [compiler args...] + + # a plain host toolchain: what gcc searches with no -I at all + python3 scripts/probe_toolchain.py -- /usr/bin/gcc + # a cross toolchain with a wrapper (OpenWrt): the driver Bazel's + # cc_toolchain_config names, with the env its `env_set` feature gives it + python3 scripts/probe_toolchain.py --env STAGING_DIR=/…/staging_dir/target-… \ + -- /…/toolchain/bin/mips64-openwrt-linux-musl-gcc + +Run it twice (with and without a wrapper's gating variable) to see what the +variable adds. Every root printed is a search directory the compiler uses +with NO `-I` on the command line -- exactly what Bazel's argv will never +show and the reference's will. +""" + +from __future__ import annotations + +import json +import os +import posixpath +import shlex +import shutil +import subprocess +import sys +from typing import Dict, List, Optional, Tuple + +# Compile-side flags that name a search directory, joined (-Ifoo) or split +# (-I foo). clang's -### spells its builtin dirs as -internal-isystem. +_INCLUDE_FLAGS = ("-I", "-isystem", "-idirafter", "-iquote", "-iprefix", + "-iwithprefix", "-iwithprefixbefore", "-isysroot", + "-internal-isystem", "-internal-externc-isystem") +# Link-side search directories. +_LINK_DIR_FLAGS = ("-L",) + + +def _norm(path: str) -> str: + """Lexical normalization only (gcc prints `bin/../lib/gcc/../../…`); no + realpath, so the printed spelling stays comparable to what CMake passed.""" + return posixpath.normpath(path.replace(os.sep, "/")) if path else path + + +def _dedup(items): + seen = set() + out = [] + for i in items: + if i not in seen: + seen.add(i) + out.append(i) + return out + + +def _split_dir_flags(argv: List[str], flags: Tuple[str, ...]) -> List[Tuple[str, str]]: + """(flag, dir) pairs from an argv, accepting `-Ifoo` and `-I foo`. Longest + flag wins so `-isystem` is not read as `-i` + `system`. `--sysroot=` and + `-rpath-link=` are handled by their own readers.""" + out = [] + i = 0 + while i < len(argv): + tok = argv[i] + hit = None + for f in sorted(flags, key=len, reverse=True): + if tok == f and i + 1 < len(argv): + hit = (f, argv[i + 1]); i += 2; break + if tok.startswith(f) and len(tok) > len(f) and not tok[len(f)].startswith("-"): + hit = (f, tok[len(f):]); i += 1; break + if hit is None: + i += 1 + continue + out.append((hit[0], _norm(hit[1]))) + return out + + +def _sysroot(argv: List[str]) -> Optional[str]: + for i, tok in enumerate(argv): + if tok.startswith("--sysroot="): + return _norm(tok[len("--sysroot="):]) + if tok == "--sysroot" and i + 1 < len(argv): + return _norm(argv[i + 1]) + return None + + +def _rpath_links(argv: List[str]) -> List[str]: + """-rpath-link=A:B, -rpath-link A:B, and the -Wl,-rpath-link=… form a + driver forwards; each colon-separated dir becomes one entry.""" + out = [] + i = 0 + while i < len(argv): + tok = argv[i] + val = None + if tok.startswith("-Wl,"): + parts = tok[4:].split(",") + for j, p in enumerate(parts): + if p.startswith("-rpath-link="): + val = p[len("-rpath-link="):] + elif p == "-rpath-link" and j + 1 < len(parts): + val = parts[j + 1] + elif tok.startswith("-rpath-link="): + val = tok[len("-rpath-link="):] + elif tok == "-rpath-link" and i + 1 < len(argv): + val = argv[i + 1]; i += 1 + if val: + out.extend(_norm(p) for p in val.split(":") if p) + i += 1 + return _dedup(out) + + +def _commands(stderr: str) -> List[List[str]]: + """The sub-commands a `-v`/`-###` run prints: lines that shlex-split into + an argv whose first token is a program path (gcc indents them with one + space; clang quotes every token). Other lines (banners, search lists, + COLLECT_* env) are not commands.""" + cmds = [] + for line in stderr.splitlines(): + if not line.startswith((" ", '"')): + continue + try: + argv = shlex.split(line) + except ValueError: + continue + if argv and ("/" in argv[0] or argv[0].endswith(("cc1", "cc1plus", "clang"))): + cmds.append(argv) + return cmds + + +def _is_compiler_proper(argv0: str) -> bool: + base = os.path.basename(argv0) + return base in ("cc1", "cc1plus", "cc1obj", "cc1objplus") or base.startswith("clang") + + +def _is_linker(argv0: str) -> bool: + base = os.path.basename(argv0) + return (base.startswith(("collect2", "ld", "lld")) or base.endswith(("-ld", ".lld")) + or base in ("ld64", "ld64.lld", "link")) + + +def parse_verbose(stderr: str, probe_args: List[str]) -> Dict: + """Read a `-v -E` run: the two search lists, ignored (nonexistent) + directories, the compiler-proper command's injected include flags, and + the driver-level options a wrapper/specs added ahead of the probe's own + args (COLLECT_GCC_OPTIONS minus what we passed).""" + quote: List[str] = [] + angle: List[str] = [] + nonexistent: List[str] = [] + collect_opts: List[str] = [] + mode = None + for line in stderr.splitlines(): + if line.startswith('#include "..." search starts here:'): + mode = "quote"; continue + if line.startswith("#include <...> search starts here:"): + mode = "angle"; continue + if line.startswith("End of search list."): + mode = None; continue + if line.startswith("ignoring nonexistent directory"): + q = line.split('"') + if len(q) >= 2: + nonexistent.append(_norm(q[1])) + continue + if line.startswith("COLLECT_GCC_OPTIONS="): + collect_opts = shlex.split(line[len("COLLECT_GCC_OPTIONS="):]) + continue + if mode and line.startswith(" "): + d = line.strip() + if not d: + continue + # clang annotates framework dirs: "/path (framework directory)" + if d.endswith(" (framework directory)"): + d = d[:-len(" (framework directory)")] + (quote if mode == "quote" else angle).append(_norm(d)) + own = set(probe_args) + driver_added = [o for o in collect_opts if o not in own] if collect_opts else [] + compile_cmd = next((c for c in _commands(stderr) if _is_compiler_proper(c[0])), None) + return { + "include_search": {"quote": _dedup(quote), "angle": _dedup(angle)}, + "nonexistent": _dedup(nonexistent), + "driver_options": driver_added, + "compile_command": None if compile_cmd is None else { + "argv0": compile_cmd[0], + "include_flags": _split_dir_flags(compile_cmd, _INCLUDE_FLAGS), + "sysroot": _sysroot(compile_cmd), + }, + } + + +def parse_link(stderr: str) -> Optional[Dict]: + """Read a `-###` run's link command: -L dirs, -rpath-link dirs, sysroot.""" + link = next((c for c in _commands(stderr) if _is_linker(c[0])), None) + if link is None: + return None + return { + "argv0": link[0], + "library_dirs": _dedup(d for _, d in _split_dir_flags(link, _LINK_DIR_FLAGS)), + "rpath_link": _rpath_links(link), + "sysroot": _sysroot(link), + } + + +def probe(compiler_argv: List[str], env_overrides: Dict[str, str], + lang: str = "c") -> Dict: + """Run both probes and assemble the report. Raises RuntimeError with the + driver's stderr if the compile probe fails (a wrapper refusing to run + without its gating variable is the typical cause -- pass --env).""" + env = dict(os.environ) + env.update(env_overrides) + compile_args = ["-v", "-E", "-x", lang, "/dev/null"] + p = subprocess.run(compiler_argv + compile_args, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + if p.returncode != 0: + raise RuntimeError( + f"{' '.join(compiler_argv + compile_args)} exited {p.returncode}:\n" + + p.stderr.rstrip()) + report = parse_verbose(p.stderr, compile_args) + # link probe: -### prints the commands and runs nothing; the -o target is + # never created. + link_args = ["-###", "-x", lang, "/dev/null", "-o", "/dev/null"] + q = subprocess.run(compiler_argv + link_args, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + report["link_command"] = parse_link(q.stderr) if q.returncode == 0 else None + report["link_probe_error"] = None if q.returncode == 0 else q.stderr.rstrip() + resolved = shutil.which(compiler_argv[0]) or compiler_argv[0] + dirs = report["include_search"]["quote"] + report["include_search"]["angle"] + report["realpaths"] = {d: os.path.realpath(d) for d in dirs + if os.path.isdir(d) and os.path.realpath(d) != d} + report.update({ + "compiler": compiler_argv, + "resolved": os.path.realpath(resolved) if os.path.exists(resolved) else resolved, + "env": dict(env_overrides), + "lang": lang, + # What to paste: every directory on the <...> search list is one the + # compiler searches with no -I on the command line. Verify each against + # the reference's argv before pasting (a root the reference does not + # pass is not needed -- and would fail the required-on-cmake check). + "toolchain_includes": list(report["include_search"]["angle"]), + }) + return report + + +def render(r: Dict) -> str: + L = [] + L.append(f"compiler : {' '.join(r['compiler'])}") + if r["resolved"] != r["compiler"][0]: + L.append(f" -> {r['resolved']}") + L.append("env : " + (" ".join(f"{k}={v}" for k, v in r["env"].items()) or "(inherited only)")) + L.append(f"language : {r['lang']}") + + L.append("\ndriver options added by the wrapper/specs (not in the probe's own argv):") + L.extend(f" {o}" for o in r["driver_options"]) if r["driver_options"] else L.append(" (none reported)") + + cc = r.get("compile_command") + L.append("\ncompile: include flags the driver injected into the compiler-proper command:") + if cc is None: + L.append(" (no cc1/clang command found in -v output)") + else: + L.append(f" [{cc['argv0']}]") + if cc["sysroot"]: + L.append(f" --sysroot {cc['sysroot']}") + if cc["include_flags"]: + L.extend(f" {f} {d}" for f, d in cc["include_flags"]) + else: + L.append(" (none)") + + real = r.get("realpaths", {}) + for key, title in (("quote", '#include "..." search list:'), + ("angle", "#include <...> search list:")): + L.append(f"\n{title}") + dirs = r["include_search"][key] + if not dirs: + L.append(" (none)") + # a symlinked dir is shown with its target: the reference may spell + # the same directory the other way, and the config must use the + # reference's spelling (the diff compares paths, never the disk) + L.extend(f" {d}" + (f" (-> {real[d]})" if d in real else "") for d in dirs) + if r["nonexistent"]: + L.append("\nignored nonexistent directories:") + L.extend(f" {d}" for d in r["nonexistent"]) + + lk = r.get("link_command") + L.append("\nlink: search paths the driver injected into the linker command:") + if lk is None: + L.append(" (no linker command found)" + + (f"; -### failed:\n{r['link_probe_error']}" if r.get("link_probe_error") else "")) + else: + L.append(f" [{lk['argv0']}]") + if lk["sysroot"]: + L.append(f" --sysroot {lk['sysroot']}") + L.extend(f" -L {d}" for d in lk["library_dirs"]) + L.extend(f" -rpath-link {d}" for d in lk["rpath_link"]) + if not lk["library_dirs"] and not lk["rpath_link"]: + L.append(" (none)") + + L.append("\nany2bazel.json -- keep only the roots the reference passes on every TU," + "\nspelled the way the reference's argv spells them:") + L.append(' "toolchain_includes": ' + json.dumps(r["toolchain_includes"], indent=4)) + return "\n".join(L) + + +def main(argv: List[str]) -> int: + env: Dict[str, str] = {} + lang = "c" + as_json = False + i = 0 + while i < len(argv) and argv[i] != "--": + a = argv[i] + if a == "--env" and i + 1 < len(argv) and "=" in argv[i + 1]: + k, v = argv[i + 1].split("=", 1); env[k] = v; i += 2; continue + if a == "--lang" and i + 1 < len(argv): + lang = argv[i + 1]; i += 2; continue + if a == "--json": + as_json = True; i += 1; continue + break + compiler = argv[i + 1:] if i < len(argv) and argv[i] == "--" else argv[i:] + if not compiler: + print("usage: probe_toolchain.py [--env KEY=VAL]... [--lang c|c++] [--json] " + "-- [args...]", file=sys.stderr) + return 2 + try: + r = probe(compiler, env, lang) + except (RuntimeError, OSError) as e: + print(f"probe_toolchain: {e}", file=sys.stderr) + return 1 + print(json.dumps(r, indent=2) if as_json else render(r)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_probe_toolchain.py b/tests/test_probe_toolchain.py new file mode 100644 index 0000000..7dd46ab --- /dev/null +++ b/tests/test_probe_toolchain.py @@ -0,0 +1,233 @@ +# Copyright 2026 EngFlow Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for probe_toolchain.py (what does the compiler inject?). + +The parsers are exercised on captured gcc / clang / OpenWrt-driver output +shapes, and the end-to-end probe on a FAKE compiler: a shell script that +behaves like OpenWrt's patched gcc driver -- it adds `-idirafter +$STAGING_DIR/usr/include` and `-L$STAGING_DIR/usr/lib` only when STAGING_DIR +is set -- so the env-gated case is covered without a cross toolchain. +""" + +import json +import os +import stat +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +from probe_toolchain import main, parse_link, parse_verbose, probe + +_PROBE = os.path.join(os.path.dirname(__file__), "..", "scripts", "probe_toolchain.py") + +# A gcc-shaped `-v -E` stderr, the OpenWrt driver's: the shell wrapper's +# --sysroot shows in COLLECT_GCC_OPTIONS, the patched driver's -idirafter on +# the cc1 line, one nonexistent dir, and the two search lists. +_GCC_VERBOSE = """\ +Reading specs from /tc/lib/gcc/mips64-openwrt-linux-musl/14.4.0/specs +Target: mips64-openwrt-linux-musl +COLLECT_GCC_OPTIONS='--sysroot=/tc/bin//..' '-v' '-E' '-x' 'c' '-march=mips64' '-EB' + /tc/bin/../libexec/gcc/mips64-openwrt-linux-musl/14.4.0/cc1 -E -quiet -v -iprefix /tc/bin/../lib/gcc/mips64-openwrt-linux-musl/14.4.0/ -isysroot /tc/bin//.. -idirafter /stg/usr/include /dev/null -meb -march=mips64 +ignoring nonexistent directory "/tc/bin//../usr/local/include" +ignoring duplicate directory "/tc/bin/../lib/gcc/../../lib/gcc/mips64-openwrt-linux-musl/14.4.0/include" +#include "..." search starts here: +#include <...> search starts here: + /tc/bin/../lib/gcc/mips64-openwrt-linux-musl/14.4.0/../../../../mips64-openwrt-linux-musl/sys-include + /tc/bin/../lib/gcc/mips64-openwrt-linux-musl/14.4.0/include + /stg/usr/include +End of search list. +COMPILER_PATH=/tc/bin/../libexec/gcc/mips64-openwrt-linux-musl/14.4.0/ +COLLECT_GCC_OPTIONS='--sysroot=/tc/bin//..' '-v' '-E' '-x' 'c' '-march=mips64' '-EB' +""" + +_GCC_LINK = """\ +COLLECT_GCC_OPTIONS='--sysroot=/tc/bin//..' '-o' '/dev/null' '-march=mips64' + /tc/bin/../libexec/gcc/mips64-openwrt-linux-musl/14.4.0/cc1 -quiet -idirafter /stg/usr/include /dev/null -o /tmp/cc1.s + /tc/bin/../lib/gcc/mips64-openwrt-linux-musl/14.4.0/../../../../mips64-openwrt-linux-musl/bin/as -EB -o /tmp/cc1.o /tmp/cc1.s + /tc/bin/../libexec/gcc/mips64-openwrt-linux-musl/14.4.0/collect2 -plugin /tc/liblto_plugin.so "--sysroot=/tc/bin//.." --eh-frame-hdr -o /dev/null -L /stg/usr/lib -rpath-link /stg/usr/lib -L/tc/bin/../lib/gcc/mips64-openwrt-linux-musl/14.4.0 -L/tc/bin/../lib/gcc /tmp/cc1.o -lgcc "-rpath-link=/tc/bin//../lib:/tc/bin//../usr/lib" -lc +""" + +# clang quotes every token and spells builtin dirs -internal-isystem. +_CLANG_VERBOSE = """\ +clang version 23.1.0 + "/opt/llvm/bin/clang-23" "-cc1" "-triple" "x86_64-unknown-linux-gnu" "-E" "-internal-isystem" "/opt/llvm/lib/clang/23/include" "-internal-externc-isystem" "/usr/include" "-x" "c" "/dev/null" +#include "..." search starts here: +#include <...> search starts here: + /opt/llvm/lib/clang/23/include + /usr/include +End of search list. +""" + +_CLANG_LINK = """\ + "/opt/llvm/bin/clang-23" "-cc1" "-triple" "x86_64-unknown-linux-gnu" "-emit-obj" "-x" "c" "/dev/null" "-o" "/tmp/x.o" + "/usr/bin/ld" "--sysroot=/" "-o" "/dev/null" "-L/opt/llvm/lib/clang/23/lib/x86_64-unknown-linux-gnu" "-L/usr/lib64" "/tmp/x.o" "-lc" +""" + +# The fake driver: OpenWrt's behaviour in 20 lines of sh. Prints gcc-shaped +# -v output on `-v`, gcc-shaped command lines on `-###`, and gates the +# staging-dir injection on STAGING_DIR exactly like the patched gcc.c. +_FAKE_CC = r"""#!/bin/sh +extra_i=""; extra_l="" +if [ -n "$STAGING_DIR" ]; then + extra_i="-idirafter $STAGING_DIR/usr/include" + extra_l="-L$STAGING_DIR/usr/lib -rpath-link=$STAGING_DIR/usr/lib" +fi +case " $* " in + *" -### "*) + echo " /fake/libexec/cc1 -quiet -isysroot /fake $extra_i /dev/null -o /tmp/f.s" >&2 + echo " /fake/libexec/collect2 --sysroot=/fake -o /dev/null $extra_l -L/fake/lib/gcc /tmp/f.o -lc" >&2 + ;; + *" -v "*) + cat >&2 < search starts here: + /fake/bin/../include + ${STAGING_DIR:+$STAGING_DIR/usr/include} +End of search list. +EOF + ;; + *) echo "fake-cc: unexpected args: $*" >&2; exit 3 ;; +esac +exit 0 +""" + + +def _fake_compiler(body=_FAKE_CC): + d = tempfile.mkdtemp() + path = os.path.join(d, "fake-gcc") + with open(path, "w") as f: + f.write(body) + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR) + return path + + +def test_parse_verbose_gcc_shape(): + r = parse_verbose(_GCC_VERBOSE, ["-v", "-E", "-x", "c", "/dev/null"]) + # search list: normalized (bin/../lib/gcc/../../.. collapsed), in order + assert r["include_search"]["angle"] == [ + "/tc/mips64-openwrt-linux-musl/sys-include", + "/tc/lib/gcc/mips64-openwrt-linux-musl/14.4.0/include", + "/stg/usr/include"], r + assert r["include_search"]["quote"] == [] + assert r["nonexistent"] == ["/tc/usr/local/include"] + # the driver-level options minus the probe's own args = what the wrapper + # and the specs prepended + assert r["driver_options"] == ["--sysroot=/tc/bin//..", "-march=mips64", "-EB"], r + cc = r["compile_command"] + assert cc["argv0"].endswith("/cc1") + assert ("-idirafter", "/stg/usr/include") in cc["include_flags"] + assert ("-isysroot", "/tc") in cc["include_flags"] + assert ("-iprefix", "/tc/lib/gcc/mips64-openwrt-linux-musl/14.4.0") in cc["include_flags"] + + +def test_parse_link_gcc_shape(): + lk = parse_link(_GCC_LINK) + assert lk["argv0"].endswith("/collect2") + assert lk["sysroot"] == "/tc" + assert lk["library_dirs"] == [ + "/stg/usr/lib", "/tc/lib/gcc/mips64-openwrt-linux-musl/14.4.0", "/tc/lib/gcc"], lk + # split and joined -rpath-link forms, colon lists exploded, deduped + assert lk["rpath_link"] == ["/stg/usr/lib", "/tc/lib", "/tc/usr/lib"], lk + + +def test_parse_clang_shapes(): + r = parse_verbose(_CLANG_VERBOSE, ["-v", "-E", "-x", "c", "/dev/null"]) + assert r["include_search"]["angle"] == ["/opt/llvm/lib/clang/23/include", "/usr/include"] + assert r["driver_options"] == [] # clang prints no COLLECT_GCC_OPTIONS + cc = r["compile_command"] + assert cc["argv0"] == "/opt/llvm/bin/clang-23" + assert ("-internal-isystem", "/opt/llvm/lib/clang/23/include") in cc["include_flags"] + assert ("-internal-externc-isystem", "/usr/include") in cc["include_flags"] + lk = parse_link(_CLANG_LINK) + assert lk["argv0"] == "/usr/bin/ld" + assert lk["library_dirs"] == [ + "/opt/llvm/lib/clang/23/lib/x86_64-unknown-linux-gnu", "/usr/lib64"], lk + assert lk["sysroot"] == "/" + + +def test_probe_env_gated_injection_is_visible(): + # Without STAGING_DIR the fake driver injects nothing; with it, the + # staging root appears on the search list, as a cc1 -idirafter and as a + # link -L / -rpath-link. The env is passed to the probe, never inherited + # by accident (the test's own environment has no STAGING_DIR). + cc = _fake_compiler() + os.environ.pop("STAGING_DIR", None) + bare = probe([cc], {}) + assert bare["toolchain_includes"] == ["/fake/include"], bare + assert bare["compile_command"]["include_flags"] == [("-isysroot", "/fake")] + assert bare["link_command"]["library_dirs"] == ["/fake/lib/gcc"] + assert bare["link_command"]["rpath_link"] == [] + + staged = probe([cc], {"STAGING_DIR": "/stg"}) + assert staged["toolchain_includes"] == ["/fake/include", "/stg/usr/include"], staged + assert ("-idirafter", "/stg/usr/include") in staged["compile_command"]["include_flags"] + assert staged["link_command"]["library_dirs"] == ["/stg/usr/lib", "/fake/lib/gcc"] + assert staged["link_command"]["rpath_link"] == ["/stg/usr/lib"] + assert staged["driver_options"] == ["--sysroot=/fake"] + assert staged["env"] == {"STAGING_DIR": "/stg"} + assert staged["nonexistent"] == ["/fake/usr/local/include"] + # the fake driver is not a symlink, so nothing to resolve + assert staged["realpaths"] == {} + + +def test_cli_json_and_text(): + cc = _fake_compiler() + p = subprocess.run([sys.executable, _PROBE, "--env", "STAGING_DIR=/stg", "--json", + "--", cc], capture_output=True, text=True, check=True) + r = json.loads(p.stdout) + assert r["toolchain_includes"] == ["/fake/include", "/stg/usr/include"] + assert r["compiler"] == [cc] and r["lang"] == "c" + p = subprocess.run([sys.executable, _PROBE, "--env", "STAGING_DIR=/stg", "--", cc], + capture_output=True, text=True, check=True) + assert "-idirafter /stg/usr/include" in p.stdout + assert "-L /stg/usr/lib" in p.stdout + assert '"toolchain_includes"' in p.stdout and '"/stg/usr/include"' in p.stdout + # --lang is forwarded as -x + p = subprocess.run([sys.executable, _PROBE, "--lang", "c++", "--json", "--", cc], + capture_output=True, text=True, check=True) + assert json.loads(p.stdout)["lang"] == "c++" + + +def test_failing_compiler_is_an_error_with_its_stderr(): + # a wrapper that refuses to run (e.g. without its gating variable) must + # fail the probe loudly, with the driver's own message, not print an + # empty search list + cc = _fake_compiler("#!/bin/sh\necho 'STAGING_DIR not defined' >&2\nexit 1\n") + import io + from contextlib import redirect_stderr + err = io.StringIO() + with redirect_stderr(err): + rc = main(["--", cc]) + assert rc == 1 + assert "STAGING_DIR not defined" in err.getvalue() + assert main([]) == 2 # usage + + +if __name__ == "__main__": + import traceback + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for fn in fns: + try: + fn(); print(f"PASS {fn.__name__}") + except Exception: + failed += 1; print(f"FAIL {fn.__name__}"); traceback.print_exc() + print(f"\n{len(fns) - failed}/{len(fns)} passed") + sys.exit(1 if failed else 0) From 852f1f75aba1127a08786b67e7dadd1d4c50fc80 Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Sun, 27 Sep 2026 08:10:00 +0200 Subject: [PATCH 3/3] SKILL.md: document toolchain_includes and probe_toolchain.py A config-reference entry for the new field (what it asserts on each side, directory identity rather than prefix, and that the roots come from the probe, not from memory), a second reading of includes_diff in the fix table, and the probe's tests in the test command. Co-Authored-By: Claude Opus 5.5 (1M context) --- SKILL.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1b5af20..aa25a3f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -102,6 +102,7 @@ reads it today): "dep_map": { "Catch2Main": "catch2_main" }, "exclude_targets": ["benchmark", "some_tool"], "include_tests": false, + "toolchain_includes": ["/opt/sdk/usr/include"], "ignore": { "defines": ["BORINGSSL_DISPATCH_TEST"], "flags": ["-fvisibility=hidden"], @@ -154,6 +155,17 @@ Fields: wins). `include_prefixes` just deletes the path (a blind spot) — use only when there's no counterpart to map to. Search **order** is not enforced (presence only) — see `docs/FUTURE-include-order-collision-check.md`. +- **`toolchain_includes`** (top level) — include roots the Bazel toolchain adds + **at execution time** (a compiler wrapper's `-idirafter /usr/include`, + sysroot builtin dirs), so `aquery` never shows them while the reference spells + them out on every TU. An **assertion, not a suppression**: each root is + *required* on every CMake TU (absence is an `includes_diff` error) and counts + as present on the Bazel side. Matched by directory identity, not prefix, so a + root beneath it (`/usr/include/json-c`) is still verified. Don't guess + the roots — ask the driver, with the command and env the Bazel toolchain uses: + `python3 scripts/probe_toolchain.py [--env K=V] -- ` prints its + search lists, injected include flags and a snippet to paste. A plain host + build whose reference passes no such roots needs none. The `ignore` and `target_map`/`exclude_targets` lists are applied at **diff time** to **both sides**, so you can tune them and re-diff without re-running @@ -313,7 +325,7 @@ correctness flags). | `missing_target` | add the missing `cc_binary`, or `target_map` a renamed exe, or `exclude_targets` if out of scope | | `missing_tu` | add the source to some library's `srcs`, or `exclude_targets` if it's a vendored/out-of-scope subtree | | `defines_diff` | add each `cmake_only` define to `defines`, or `ignore.defines` it | -| `includes_diff` | add the missing CMake include root to `includes`; for a dep whose root is spelled differently each side, `ignore.include_map` it (preferred) or `ignore.include_prefixes` it | +| `includes_diff` | add the missing CMake include root to `includes`; for a dep whose root is spelled differently each side, `ignore.include_map` it (preferred) or `ignore.include_prefixes` it; a root the compiler wrapper/sysroot adds itself goes in `toolchain_includes`. Under `bazel_only` with a `toolchain_includes` detail: the reference no longer carries a declared root | | `flags_diff` | add each `cmake_only` flag to `copts`, or `ignore.flags` it | | `link_flags_diff`| add each `cmake_only` flag to the target's `linkopts`, or `ignore.link_flags` it if benign (e.g. a compile flag CMake repeats at link) | | `missing_dep` | add the missing external/system dep to the target's `deps`/`linkopts`; if it's just a name spelled differently per build (`Catch2Main` vs `catch2_main`, `OpenSSL::SSL` vs `ssl`), add a `dep_map` entry. External deps are captured from both `-l` flags and archive-file inputs (e.g. `external/catch2+/libcatch2_main.a`) | @@ -399,7 +411,8 @@ build brings (no action graph to extract, traversal-order-dependent output, ```bash python3 tests/test_engine.py && python3 tests/test_extractors.py \ && python3 tests/test_maven.py && python3 tests/test_extract_npm.py \ - && python3 tests/test_triage.py && python3 tests/test_configure.py + && python3 tests/test_triage.py && python3 tests/test_configure.py \ + && python3 tests/test_probe_toolchain.py ``` Extractor tests run against fixtures that mirror the documented File API,