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, 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/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/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_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) 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"]]