| # Copyright 2026 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Manage Ninja implicit inputs, see README.md file for details.""" |
| |
| import collections |
| import dataclasses |
| import json |
| import os |
| import sys |
| import tempfile |
| import typing as T |
| from pathlib import Path |
| |
| _SCRIPT_DIR = os.path.dirname(__file__) |
| sys.path.insert(0, os.path.join(_SCRIPT_DIR, "../api")) |
| from gn_labels import GnLabelQualifier |
| from gn_ninja_outputs import NinjaOutputsBase, NinjaOutputsJSON |
| from ninja_artifacts import NinjaRunner |
| |
| # A type representing a dictionary mapping Ninja target paths to a set of related path strings. |
| # (which can represent inputs or outputs computed by walking the Ninja graph). |
| # All paths are relative to the Ninja build directory and stored as simple strings. |
| NinjaTargetPaths: T.TypeAlias = dict[str, set[str]] |
| |
| # A type for a dictionary mapping GN labels to the set of implicit and unknown |
| # source paths they depend on. The paths will be relative to the Fuchsia source |
| # directory. |
| GnSourcePaths: T.TypeAlias = dict[str, set[str]] |
| |
| |
| def run_ninja_tool( |
| tool_name: str, |
| target_paths: T.Iterable[str], |
| ninja_runner: NinjaRunner, |
| with_depfile: bool, |
| ) -> NinjaTargetPaths: |
| """run the ninja 'affected' or 'multi-inputs' tools and return its output as a map. |
| |
| these tools takes one or more Ninja target paths as input (which |
| must be relative to the ninja build dir), and will print lines |
| with the following format: |
| |
| <target> <tab> <path> |
| |
| Where <path> is an output file path for the 'affected' tool, and a input |
| file path for the 'multi-inputs' tool. |
| |
| args: |
| tool_name: Must be either "affected" or "multi-inputs" |
| target_paths: a sequence of Ninja target paths, relative to |
| the ninja build directory. |
| |
| ninja_runner: A NinjaRunner instance. |
| |
| with_depfile: set to True to return results that include the |
| implicit inputs recorded in the depfile, false otherwise. |
| |
| returns: |
| a NinjaTargetsPath instance corresponding to the result, mapping each input |
| target path value to a set of related Ninja paths. |
| """ |
| result: NinjaTargetPaths = collections.defaultdict(set) |
| |
| # Create a temporary directory to list all root targets, then use |
| # --target-list=FILE in the tool query to avoid command-line length limits |
| with tempfile.NamedTemporaryFile("w+") as tmp_list: |
| tmp_list.write("\n".join(target_paths)) |
| tmp_list.flush() # Important to ensure file is written to disk. |
| |
| cmd_args = ["-t", tool_name] |
| if tool_name == "affected": |
| cmd_args += [ |
| "--depth=1", |
| "--partition", |
| ] |
| elif tool_name == "multi-inputs": |
| pass |
| else: |
| assert ( |
| False |
| ), f"Unknown tool_name {tool_name}, must be one of: affected, multi-inputs" |
| |
| if with_depfile: |
| cmd_args += ["--depfile"] |
| |
| cmd_args += ["--target-list={}".format(tmp_list.name)] |
| output = ninja_runner.run_and_extract_output(cmd_args) |
| |
| for line in output.splitlines(): |
| target_path, tab, output_path = line.partition("\t") |
| assert ( |
| tab == "\t" |
| ), f"unexpected '-t affected' output line (expected <source><tab><output>): [{line}]" |
| result[target_path].add(output_path) |
| |
| return result |
| |
| |
| @dataclasses.dataclass |
| class ImplicitInputsEntry: |
| """Model the known implicit input file and directory paths for a given GN target. |
| |
| gn_label is a fully-qualified GN label, and all file paths are relative to the |
| Ninja build directory. |
| """ |
| |
| gn_label: str = "" |
| files: None | set[str] = None |
| directories: None | set[str] = None |
| |
| |
| class ImplicitInputs: |
| """The set of known implicit inputs from the //build/ninja_implicit_inputs:manifest.""" |
| |
| def __init__( |
| self, manifest_map: dict[str, list[ImplicitInputsEntry]] |
| ) -> None: |
| """Create instance. Use create_from_build_dir() instead.""" |
| self._map = manifest_map |
| self._entries: None | list[ImplicitInputsEntry] = None |
| self._known_files: None | set[str] = None |
| self._known_dirs: None | set[str] = None |
| |
| @property |
| def all_known_files(self) -> set[str]: |
| """Return the set of all known files from all root targets.""" |
| if self._known_files is None: |
| self._known_files = set() |
| for entries in self._map.values(): |
| for entry in entries: |
| if entry.files: |
| self._known_files.update(entry.files) |
| return self._known_files |
| |
| @property |
| def all_known_dirs(self) -> set[str]: |
| """Return the set of all known directories from all root targets.""" |
| if self._known_dirs is None: |
| self._known_dirs = set() |
| for entries in self._map.values(): |
| for entry in entries: |
| if entry.directories: |
| self._known_dirs.update(entry.directories) |
| return self._known_dirs |
| |
| @property |
| def all_entries(self) -> list[ImplicitInputsEntry]: |
| """Return the list of all entries from all root targets, sorted by GN label.""" |
| if self._entries is None: |
| self._entries = [] |
| entries_map: dict[str, ImplicitInputsEntry] = {} |
| for entries in self._map.values(): |
| for entry in entries: |
| cur_value = entries_map.setdefault(entry.gn_label, entry) |
| if cur_value != entry: |
| continue |
| self._entries = sorted( |
| entries_map.values(), key=lambda x: x.gn_label |
| ) |
| return self._entries |
| |
| @property |
| def map(self) -> dict[str, list[ImplicitInputsEntry]]: |
| """Return the map from Ninja root targets to implicit inputs.""" |
| return self._map |
| |
| def check_for_missing_files( |
| self, fuchsia_dir: str | Path, build_dir: str | Path |
| ) -> GnSourcePaths: |
| """Check for missing files or directories. |
| |
| This is useful to verify that implicit file or directory declarations |
| (e.g. C++ headers) actually exist. |
| |
| Args: |
| fuchsia_dir: Fuchsia source directory. |
| build_dir: Ninja build directory. |
| Returns: |
| A dictionary mapping GN labels to the set of missing file or |
| directory paths found by the function. This will be empty if |
| all files are found. All listed paths are relative to build_dir. |
| """ |
| source_prefix = os.path.relpath(fuchsia_dir, build_dir) + "/" |
| implicit_entries = self.all_entries |
| missing_paths_map: dict[str, set[str]] = collections.defaultdict(set) |
| for entry in implicit_entries: |
| for file in entry.files or []: |
| if not file.startswith(source_prefix): |
| continue # Ignore build artifacts |
| filepath = os.path.normpath(os.path.join(build_dir, file)) |
| if not os.path.isfile(filepath): |
| missing_paths_map[entry.gn_label].add(file) |
| for directory in entry.directories or []: |
| if not directory.startswith(source_prefix): |
| continue # Ignore build artifacts |
| dirpath = os.path.normpath(os.path.join(build_dir, directory)) |
| if not os.path.isdir(dirpath): |
| missing_paths_map[entry.gn_label].add(directory) |
| |
| return missing_paths_map |
| |
| @staticmethod |
| def create_from_build_dir(build_dir: str | Path) -> "ImplicitInputs": |
| """Load the GN-generated manifest of known implicit inputs. |
| |
| Args: |
| build_dir: Path to the Ninja build directory. |
| Returns: |
| An ImplicitInputs instance. |
| """ |
| manifest_path = os.path.join( |
| build_dir, "gen/build/ninja_implicit_inputs/manifest.json" |
| ) |
| assert os.path.exists( |
| manifest_path |
| ), f"Missing input manifest: {manifest_path}" |
| with open(manifest_path) as f: |
| manifest = json.load(f) |
| |
| known_implicit_files: set[str] = set() |
| known_implicit_dirs: set[str] = set() |
| |
| manifest_map: dict[str, list[ImplicitInputsEntry]] = {} |
| for manifest_entry in manifest: |
| # LINT.IfChange(manifest_schema) |
| assert ( |
| "gn_label" in manifest_entry |
| ), f"Invalid entry, missing 'gn_label' key: {manifest_entry}" |
| gn_label = manifest_entry["gn_label"] |
| |
| assert ( |
| "manifest_path" in manifest_entry |
| ), f"Invalid entry, missing 'manifest_path' key: {manifest_entry}" |
| submanifest_path = os.path.join( |
| build_dir, manifest_entry["manifest_path"] |
| ) |
| with open(submanifest_path) as f: |
| submanifest = json.load(f) |
| |
| entries: list[ImplicitInputsEntry] = [] |
| for submanifest_entry in submanifest: |
| entry = ImplicitInputsEntry( |
| gn_label=submanifest_entry["gn_label"], |
| files=submanifest_entry.get("files"), |
| directories=submanifest_entry.get("directories"), |
| ) |
| entries.append(entry) |
| |
| # LINT.ThenChange(//build/ninja_implicit_inputs/BUILD.gn:manifest_schema) |
| manifest_map[gn_label] = entries |
| |
| return ImplicitInputs(manifest_map) |
| |
| |
| def find_ninja_source_inputs( |
| build_targets: list[str], |
| ninja_runner: NinjaRunner, |
| with_depfile: bool, |
| ) -> NinjaTargetPaths: |
| """Find the set of explicit source inputs from the Ninja build graph. |
| |
| Args: |
| build_targets: A set of root Ninja target paths. |
| ninja_runner: A NinjaRunner instance. |
| with_depfile: Set to True to include results from the Ninja deps |
| log. Note that this will only be accurate after a build |
| that generated the build_targets artifacts. |
| Returns: |
| A NinjaTargetPaths instance mapping each build target to the set |
| of input source files it depends on, not that the source paths |
| are relative to the Ninja build directory, and will always start |
| with "../". |
| """ |
| all_inputs = run_ninja_tool( |
| "multi-inputs", build_targets, ninja_runner, with_depfile=with_depfile |
| ) |
| return { |
| build_target: {path for path in paths if path.startswith("../")} |
| for build_target, paths in all_inputs.items() |
| } |
| |
| |
| def find_unknown_implicit_source_inputs( |
| build_targets: list[str], |
| implicit_inputs: ImplicitInputs, |
| ninja_runner: NinjaRunner, |
| ) -> NinjaTargetPaths: |
| """Parse the Ninja deps log to find unknown implicit inputs there. |
| |
| Args: |
| build_targets: A sequence of Ninja build target paths. Their |
| recursive inputs will be collected. |
| |
| implicit_inputs: An ImplicitInputs instance modeling the known |
| inputs from the //build/ninja_implicit_inputs:manifest. |
| |
| ninja_runner: A NinjaRunner instance. |
| Returns: |
| A NinjaTargetPaths instance mapping each build target to the set |
| of unknown implicit source inputs for them. |
| """ |
| result: dict[str, set[str]] = collections.defaultdict(set) |
| |
| inputs_sans_depfile = find_ninja_source_inputs( |
| build_targets, ninja_runner, with_depfile=False |
| ) |
| |
| inputs_with_depfile = find_ninja_source_inputs( |
| build_targets, ninja_runner, with_depfile=True |
| ) |
| |
| known_files = implicit_inputs.all_known_files |
| known_dir_prefixes = [ |
| f"{known_dir}/" for known_dir in implicit_inputs.all_known_dirs |
| ] |
| |
| def is_in_known_dir(path: str) -> bool: |
| for known_dir_prefix in known_dir_prefixes: |
| if path.startswith(known_dir_prefix): |
| return True |
| return False |
| |
| for build_target, with_depfile_inputs in inputs_with_depfile.items(): |
| unknown_inputs = with_depfile_inputs - inputs_sans_depfile.get( |
| build_target, set() |
| ) |
| for input_path in unknown_inputs: |
| # Ignore source input paths that are in known_implicit_files. |
| if input_path in known_files or is_in_known_dir(input_path): |
| continue |
| |
| result[build_target].add(input_path) |
| |
| return result |
| |
| |
| def map_implicit_source_inputs( |
| implicit_sources: T.Iterable[str], |
| fuchsia_dir: str | Path, |
| ninja_runner: NinjaRunner, |
| ninja_outputs: NinjaOutputsBase, |
| ) -> GnSourcePaths: |
| """Map a list of unknown implicit inputs to GN labels. |
| |
| The result, when not empty can be passed to print_implicit_source_inputs_error(). |
| |
| Args: |
| implicit_sources: A sequence of paths, relative to the Ninja build |
| directory, detailing unknown implicit source inputs. |
| fuchsia_dir: Path to Fuchsia source directory. |
| ninja_runner: A NinjaRunner instance. |
| ninja_outputs: A NinjaOutputsBase instance, used to map |
| Ninja output paths to their corresponding GN target label. |
| |
| Returns: |
| A GnSourcePaths instance mapping GN labels to the set of input source |
| paths it depends on. |
| """ |
| # run the ninja affected tool to know which output files are affected by a given |
| # implicit input. then use that to get the corresponding gn target label. |
| |
| # all source inputs begin with a prefix like ../../../../ that points |
| # to the fuchsia source directory from the ninja sub-build directory. |
| source_prefix = os.path.relpath(fuchsia_dir, ninja_runner.build_dir) + "/" |
| |
| gn_label_to_sources: GnSourcePaths = collections.defaultdict(set) |
| |
| all_source_paths = list(implicit_sources) |
| while all_source_paths: |
| # Limit the number of source path listed on the tool to avoid "Argument list too long" errors |
| # when invoking Ninja. |
| count = min(500, len(all_source_paths)) |
| source_paths = all_source_paths[:count] |
| all_source_paths = all_source_paths[count:] |
| |
| affected_with_depfile = run_ninja_tool( |
| "affected", source_paths, ninja_runner, with_depfile=True |
| ) |
| affected_sans_depfile = run_ninja_tool( |
| "affected", source_paths, ninja_runner, with_depfile=False |
| ) |
| for source_path, with_depfile_outputs in affected_with_depfile.items(): |
| sans_depfile_outputs: set[str] = affected_sans_depfile.get( |
| source_path, set() |
| ) |
| only_depfile_outputs = with_depfile_outputs - sans_depfile_outputs |
| for output in only_depfile_outputs: |
| gn_label = ninja_outputs.path_to_gn_label(output) |
| # A few output paths do not have a GN label, for |
| # example build.ninja.stamp, ignore them |
| if gn_label: |
| gn_label_to_sources[gn_label].add( |
| source_path.removeprefix(source_prefix) |
| ) |
| |
| return gn_label_to_sources |
| |
| |
| def print_missing_source_inputs_error( |
| gn_missing_paths: GnSourcePaths, |
| fuchsia_dir: str | Path, |
| build_dir: str | Path, |
| out: T.TextIO, |
| ) -> None: |
| """Print an error message detailiong all missing source inputs. |
| |
| Args: |
| gn_missing_paths: A GnSourcePaths instance. |
| fuchsia_dir: Path to Fuchsia directory. |
| build_dir: Path to Ninja build directory. |
| out: The text output stream to use. |
| """ |
| print( |
| f"ERROR: The following {len(gn_missing_paths)} GN targets have missing inputs", |
| file=out, |
| ) |
| for gn_label, sources in sorted(gn_missing_paths.items()): |
| print(f"\n{gn_label}", file=out) |
| for source in sorted(sources): |
| path = os.path.relpath(os.path.join(build_dir, source), fuchsia_dir) |
| print(f" {path}", file=out) |
| print("", file=out) |
| print( |
| f"""The most common reasons to see this error are the following: |
| |
| - A C++ header that does not exist, for example due to a typographic error. |
| These are neither detected by GN nor Ninja. Either remove them if they are |
| obsolete, or fix their path in the target declaration. |
| |
| - A ninja_implicit_inputs_file() target that points to a directory instead of |
| a file, or a ninja_implicit_inputs_directory() that points to a file instead |
| of a directory. |
| |
| If this doesn't correspond to any of these cases, you might want to file a bug at |
| go/fuchsia-build-bug with reproduction steps. |
| |
| """, |
| file=out, |
| ) |
| |
| |
| def print_implicit_source_inputs_error( |
| gn_source_paths: GnSourcePaths, |
| out: T.TextIO, |
| ) -> None: |
| """Print an error message detailing all the implicit inputs. |
| |
| This will look like: |
| |
| ``` |
| ERROR: The following <COUNT> GN targets use implicit source inputs: |
| |
| //third_party/boringssl:crypto-static(//build/toolchain:host_x64) |
| third_party/boringssl/src/crypto/fipsmodule/aes/aes.cc.inc |
| third_party/boringssl/src/crypto/fipsmodule/aes/aes_nohw.cc.inc |
| ... |
| |
| //src/lib/zbitl:zbitl(//build/toolchain:host_x64) |
| sdk/lib/zbi-format/include/lib/zbi-format/internal/debugdata.h |
| ... |
| |
| ``` |
| |
| Followed by instructions on how to fix common issues. |
| |
| Args: |
| gn_source_paths: A GnSourcePaths instance. |
| out: The text output stream to use. |
| """ |
| print( |
| f"ERROR: The following {len(gn_source_paths)} GN targets use undeclared source inputs:", |
| file=out, |
| ) |
| for gn_label, sources in sorted(gn_source_paths.items()): |
| print(f"\n{gn_label}", file=out) |
| for source in sorted(sources): |
| print(f" {source}", file=out) |
| print("", file=out) |
| print( |
| f"""The most common reasons to see this error are the following: |
| |
| - A C++ header that is not properly declared in the 'public' or 'sources' argument |
| of its defining GN target. Just add the header in the target definition. |
| |
| - A C++ header with an incorrect path, as GN doesn't check that the declared |
| headers actually exist! Just fix the header path. |
| |
| - A C++ header from a target dependency, which is not correctly added to the |
| dependent target's 'deps' or 'public_deps'. Add the missing dependency. |
| |
| - An action() target that is missing a source file path from its 'inputs' argument. |
| Fix it if possible. |
| |
| If this doesn't correspond to any of these cases, you might want to add a |
| ninja_implicit_file_inputs() or ninja_implicit_directory_inputs() dependency |
| to your target to specify possible extra inputs, or contact the Fuchsia build team |
| after filing a bug at go/fuchsia-build-bug with reproduction steps. |
| |
| """, |
| file=out, |
| ) |
| |
| |
| def create_ninja_runner_and_outputs( |
| ninja_tool: str | Path, build_dir: str | Path |
| ) -> tuple[NinjaRunner, NinjaOutputsBase]: |
| """Create a NinjaRunner and NinjaOutputsBase instance. |
| |
| This is a convenience for other modules that import this one, as they |
| won't have to import gn_artifacts and ninja_artifacts themselves just |
| to get these values. |
| |
| Args: |
| ninja_tool: Path to the Ninja binary to use. |
| build_dir: Path to the Ninja build directory. |
| Returns: |
| A (ninja_runner, ninja_outputs) tuple. |
| """ |
| ninja_runner = NinjaRunner(Path(ninja_tool), Path(build_dir)) |
| |
| ninja_outputs = NinjaOutputsJSON() |
| ninja_outputs.load_from_file(Path(build_dir) / "ninja_outputs.json") |
| |
| return ninja_runner, ninja_outputs |
| |
| |
| def create_gn_qualifier(build_dir: str | Path) -> GnLabelQualifier: |
| """Create a GnLabelQualifier from the content of the build directory. |
| |
| This is a convenience for other modules that import this one, as they |
| won't have to import gn_labels themselves just to get this value. |
| |
| Args: |
| build_dir: Path to the Ninja build directory. |
| Returns: |
| A gn_targets.GnQualifier object. |
| """ |
| return GnLabelQualifier.create_from_build_dir(build_dir) |