[llvm] wip: id checks (PR #173284)

Fabrice de Gans via llvm-commits llvm-commits at lists.llvm.org
Mon Dec 22 09:22:01 PST 2025


https://github.com/Steelskin updated https://github.com/llvm/llvm-project/pull/173284

>From de6379ddf5ee154cb7ff604e9d0b25aacb38c53e Mon Sep 17 00:00:00 2001
From: Fabrice de Gans <fabrice at thebrowser.company>
Date: Wed, 17 Dec 2025 15:46:52 +0100
Subject: [PATCH] wip: id checks

---
 .github/workflows/ids-check.yml    | 107 +++++++
 llvm/include/llvm/ADT/APSInt.h     |   2 +-
 llvm/utils/git/ids-check-helper.py | 441 +++++++++++++++++++++++++++++
 3 files changed, 549 insertions(+), 1 deletion(-)
 create mode 100644 .github/workflows/ids-check.yml
 create mode 100755 llvm/utils/git/ids-check-helper.py

diff --git a/.github/workflows/ids-check.yml b/.github/workflows/ids-check.yml
new file mode 100644
index 0000000000000..e375543eba31a
--- /dev/null
+++ b/.github/workflows/ids-check.yml
@@ -0,0 +1,107 @@
+name: ids-check
+on:
+  pull_request:
+    branches:
+      - main
+      - 'users/**'
+  push:
+    branches:
+      - 'main'
+      - 'release/**'
+
+permissions:
+  contents: read
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+  cancel-in-progress: true
+
+jobs:
+  build:
+    if: github.repository_owner == 'llvm'
+    name: Check LLVM_ABI annotations with ids
+    runs-on: ubuntu-24.04
+    timeout-minutes: 10
+
+    steps:
+      - uses: actions/checkout at 8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4.2.2
+        with:
+          repository: compnerd/ids
+          path: ${{ github.workspace }}/ids
+          ref: b3bf35dd13d7ff244a6a6d106fe58d0eedb5743e # main
+
+      - uses: actions/checkout at 8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4.2.2
+        with:
+          path: ${{ github.workspace }}/llvm-project
+          fetch-depth: 2
+
+      - name: Get changed files
+        id: changed-files
+        uses: tj-actions/changed-files at 24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0
+        with:
+          separator: ","
+          skip_initial_fetch: true
+          base_sha: 'HEAD~1'
+          sha: 'HEAD'
+
+      - name: Install dependencies
+        run: |
+          sudo apt install -y clang-19 ninja-build libclang-19-dev
+          pip install lit github
+
+      - name: Configure and build minimal LLVM for use by ids
+        run: |
+          cmake -B ${{ github.workspace }}/llvm-project/build/ \
+                -S ${{ github.workspace }}/llvm-project/llvm/ \
+                -D CMAKE_BUILD_TYPE=Release \
+                -D CMAKE_C_COMPILER=clang \
+                -D CMAKE_CXX_COMPILER=clang++ \
+                -D LLVM_ENABLE_PROJECTS=clang \
+                -D LLVM_TARGETS_TO_BUILD="host" \
+                -D CMAKE_EXPORT_COMPILE_COMMANDS=ON \
+                -G Ninja
+          cd ${{ github.workspace }}/llvm-project/build/
+          ninja -t targets all | grep "CommonTableGen: phony$" | grep -v "/" | sed 's/:.*//'
+
+      - name: Configure ids
+        run: |
+          cmake -B ${{ github.workspace }}/ids/build/ \
+                -S ${{ github.workspace }}/ids/ \
+                -D CMAKE_BUILD_TYPE=Release \
+                -D CMAKE_C_COMPILER=clang \
+                -D CMAKE_CXX_COMPILER=clang++ \
+                -D CMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld \
+                -D LLVM_DIR=/usr/lib/llvm-19/lib/cmake/llvm/ \
+                -D Clang_DIR=/usr/lib/llvm-19/lib/cmake/clang/ \
+                -D FILECHECK_EXECUTABLE=$(which FileCheck-19) \
+                -D LIT_EXECUTABLE=$(which lit) \
+                -G Ninja
+
+      - name: Build ids
+        run: |
+          ninja -C ${{ github.workspace }}/ids/build/ all
+
+      - name: Run ids check
+        env:
+          GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
+          CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
+        # Create an empty comments file so the upload step doesn't fail.
+        run: |
+          cd ${{ github.workspace }}/llvm-project
+          echo "[]" > comments &&
+          python ./llvm/utils/git/ids-check-helper.py \
+            --write-comment-to-file \
+            --token ${{ secrets.GITHUB_TOKEN }} \
+            --issue-number $GITHUB_PR_NUMBER \
+            --idt-path ${{ github.workspace }}/ids/build/bin/idt \
+            --compile-commands ${{ github.workspace }}/llvm-project/build/compile_commands.json \
+            --start-rev HEAD~1 \
+            --end-rev HEAD \
+            --changed-files "$CHANGED_FILES"
+
+      - uses: actions/upload-artifact at 330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+        if: always()
+        with:
+          name: workflow-args
+          path: |
+            comments
diff --git a/llvm/include/llvm/ADT/APSInt.h b/llvm/include/llvm/ADT/APSInt.h
index 88a7a6e71c817..65a3d0aa6f0b7 100644
--- a/llvm/include/llvm/ADT/APSInt.h
+++ b/llvm/include/llvm/ADT/APSInt.h
@@ -42,7 +42,7 @@ class [[nodiscard]] APSInt : public APInt {
   /// constructed APSInt is determined automatically.
   ///
   /// \param Str the string to be interpreted.
-  LLVM_ABI explicit APSInt(StringRef Str);
+  explicit APSInt(StringRef Str);
 
   /// Determine sign of this APSInt.
   ///
diff --git a/llvm/utils/git/ids-check-helper.py b/llvm/utils/git/ids-check-helper.py
new file mode 100755
index 0000000000000..f285654d1a569
--- /dev/null
+++ b/llvm/utils/git/ids-check-helper.py
@@ -0,0 +1,441 @@
+#!/usr/bin/env python3
+#
+# ====- ids-check-helper, runs ids-check from the ci or in a hook --*- python -*--==#
+#
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+#
+# ==--------------------------------------------------------------------------------------==#
+
+import argparse
+import os
+import subprocess
+import sys
+from typing import List, Optional
+
+"""
+This script is run by GitHub actions to ensure that the code in PRs properly
+labels LLVM APIs with `LLVM_ABI` so as not to break the LLVM DLL build. It can
+also be installed as a pre-commit git hook to check ABI annotations before
+submitting. The canonical source of this script is in the LLVM source tree
+under llvm/utils/git.
+
+This script uses the idt (Interface Diff Tool) to check for missing LLVM_ABI,
+LLVM_C_ABI, and DEMANGLE_ABI annotations in header files.
+
+You can install this script as a git hook by symlinking it to the .git/hooks
+directory:
+
+ln -s $(pwd)/llvm/utils/git/ids-check-helper.py .git/hooks/pre-commit
+
+You can control the exact path to idt and compile_commands.json with the
+following environment variables: $IDT_PATH and $COMPILE_COMMANDS_PATH.
+"""
+
+
+class IdsCheckArgs:
+    start_rev: str = ""
+    end_rev: str = ""
+    changed_files: List[str] = []
+    idt_path: str = ""
+    compile_commands: str = ""
+    repo: str = ""
+    token: str = ""
+    issue_number: int = 0
+    write_comment_to_file: bool = False
+    verbose: bool = True
+
+    def __init__(self, args: argparse.Namespace) -> None:
+        self.start_rev = args.start_rev
+        self.end_rev = args.end_rev
+        self.changed_files = args.changed_files
+        self.idt_path = args.idt_path
+        self.compile_commands = args.compile_commands
+        self.repo = getattr(args, "repo", "")
+        self.token = getattr(args, "token", "")
+        self.issue_number = getattr(args, "issue_number", 0)
+        self.write_comment_to_file = getattr(args, "write_comment_to_file", False)
+        self.verbose = getattr(args, "verbose", True)
+
+
+class IdsChecker:
+    """
+    Checker for LLVM ABI annotations using the idt tool.
+    """
+
+    COMMENT_TAG = "<!--LLVM IDS CHECK COMMENT-->"
+    name = "ids-check"
+    friendly_name = "LLVM ABI annotation checker"
+    comment: dict = {}
+
+    # Macro definition used for all export macros
+    MACRO_DEFINITION = '__attribute__((visibility("default")))'
+
+    @property
+    def comment_tag(self) -> str:
+        return self.COMMENT_TAG
+
+    @property
+    def instructions(self) -> str:
+        # Provide basic usage instructions
+        return f"""git diff origin/main HEAD -- 'llvm/include/llvm/**/*.h' 'llvm/include/llvm-c/**/*.h' 'llvm/include/llvm/Demangle/**/*.h'
+Then run idt on the changed files with appropriate --export-macro and --include-header flags."""
+
+    def pr_comment_text_for_diff(self, diff: str) -> str:
+        return f"""
+:warning: {self.friendly_name}, {self.name} found issues in your code. :warning:
+
+<details>
+<summary>
+You can test this locally with the following command:
+</summary>
+
+``````````bash
+{self.instructions}
+``````````
+
+:warning:
+The reproduction instructions above might return results for more than one PR
+in a stack if you are using a stacked PR workflow. You can limit the results by
+changing `origin/main` to the base branch/commit you want to compare against.
+:warning:
+
+</details>
+
+<details>
+<summary>
+View the diff from {self.name} here.
+</summary>
+
+``````````diff
+{diff}
+``````````
+
+</details>
+"""
+
+    # TODO: any type should be replaced with the correct github type, but it requires refactoring to
+    # not require the github module to be installed everywhere.
+    def find_comment(self, pr: any) -> any:
+        for comment in pr.as_issue().get_comments():
+            if self.comment_tag in comment.body:
+                return comment
+        return None
+
+    def update_pr(
+        self, comment_text: str, args: IdsCheckArgs, create_new: bool
+    ) -> None:
+        import github
+        from github import IssueComment, PullRequest
+
+        repo = github.Github(args.token).get_repo(args.repo)
+        pr = repo.get_issue(args.issue_number).as_pull_request()
+
+        comment_text = self.comment_tag + "\n\n" + comment_text
+
+        existing_comment = self.find_comment(pr)
+
+        if args.write_comment_to_file:
+            if create_new or existing_comment:
+                self.comment = {"body": comment_text}
+            if existing_comment:
+                self.comment["id"] = existing_comment.id
+            return
+
+        if existing_comment:
+            existing_comment.edit(comment_text)
+        elif create_new:
+            pr.as_issue().create_comment(comment_text)
+
+    # Define the file categories and their corresponding configurations
+    FILE_CATEGORIES = [
+        {
+            "name": "LLVM headers",
+            "patterns": ["llvm/include/llvm/**/*.h"],
+            "excludes": [
+                "llvm/include/llvm/Debuginfod/",
+                "llvm/include/llvm/Demangle/",
+            ],
+            "export_macro": "LLVM_ABI",
+            "include_header": "llvm/include/llvm/Support/Compiler.h",
+        },
+        {
+            "name": "LLVM-C headers",
+            "patterns": ["llvm/include/llvm-c/**/*.h"],
+            "excludes": [],
+            "export_macro": "LLVM_C_ABI",
+            "include_header": "llvm/include/llvm-c/Visibility.h",
+        },
+        {
+            "name": "LLVM Demangle headers",
+            "patterns": ["llvm/include/llvm/Demangle/**/*.h"],
+            "excludes": [],
+            "export_macro": "DEMANGLE_ABI",
+            "include_header": "llvm/Demangle/Visibility.h",
+        },
+    ]
+
+    def filter_files_for_category(
+        self, changed_files: List[str], category: dict
+    ) -> List[str]:
+        """Filter changed files based on category patterns and excludes."""
+        filtered = []
+        for path in changed_files:
+            # Check if file matches any pattern
+            matches_pattern = False
+            for pattern in category["patterns"]:
+                # Simple pattern matching for /**/*.h style patterns
+                pattern_prefix = pattern.replace("/**/*.h", "")
+                if path.startswith(pattern_prefix) and path.endswith(".h"):
+                    matches_pattern = True
+                    break
+
+            if not matches_pattern:
+                continue
+
+            # Check if file should be excluded
+            excluded = False
+            for exclude in category["excludes"]:
+                if path.startswith(exclude):
+                    excluded = True
+                    break
+
+            if not excluded:
+                filtered.append(path)
+
+        return filtered
+
+    def run_idt_on_files(
+        self,
+        files: List[str],
+        category: dict,
+        args: IdsCheckArgs,
+        idt_path: str,
+        compile_commands: str,
+    ) -> bool:
+        """Run idt tool on the given files with category-specific configuration."""
+        if not files:
+            return True
+
+        if args.verbose:
+            print(
+                f"Running idt on {len(files)} {category['name']} file(s)...",
+                file=sys.stderr,
+            )
+
+        for file in files:
+            cmd = [
+                idt_path,
+                "-p",
+                compile_commands,
+                "--apply-fixits",
+                "--inplace",
+                f"--export-macro={category['export_macro']}",
+                f"--include-header={category['include_header']}",
+                f"--extra-arg=-D{category['export_macro']}={self.MACRO_DEFINITION}",
+                "--extra-arg=-Wno-macro-redefined",
+                file,
+            ]
+
+            if args.verbose:
+                print(f"Running: {' '.join(cmd)}", file=sys.stderr)
+
+            proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+            if proc.returncode != 0:
+                sys.stdout.write(proc.stdout.decode("utf-8"))
+                sys.stderr.write(proc.stderr.decode("utf-8"))
+                print(
+                    f"Error: idt failed on {file} with return code {proc.returncode}",
+                    file=sys.stderr,
+                )
+                return False
+
+        return True
+
+    def get_changed_files(self, args: IdsCheckArgs) -> List[str]:
+        """Get list of changed files between revisions."""
+        if args.changed_files:
+            return args.changed_files
+
+        cmd = ["git", "diff", "--name-only", args.start_rev, args.end_rev]
+        if args.verbose:
+            print(f"Running: {' '.join(cmd)}", file=sys.stderr)
+
+        proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        if proc.returncode != 0:
+            print("Error: Failed to get changed files", file=sys.stderr)
+            sys.stderr.write(proc.stderr.decode("utf-8"))
+            return []
+
+        files = proc.stdout.decode("utf-8").strip().split("\n")
+        return [f for f in files if f]
+
+    def check_for_diff(self) -> Optional[str]:
+        """Check if there are any uncommitted changes after running idt."""
+        cmd = ["git", "diff"]
+        proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+        diff = proc.stdout.decode("utf-8")
+        if diff:
+            return diff
+        return None
+
+    def run(self, args: IdsCheckArgs) -> int:
+        """Main entry point for running ids check."""
+        # Resolve idt path: prefer command line arg, then env var
+        idt_path = args.idt_path or os.environ.get("IDT_PATH")
+        if not idt_path:
+            print(
+                "Error: idt path not specified. Use --idt-path argument or set IDT_PATH environment variable",
+                file=sys.stderr,
+            )
+            return 1
+
+        if not os.path.exists(idt_path):
+            print(f"Error: idt tool not found at {idt_path}", file=sys.stderr)
+            return 1
+
+        # Resolve compile_commands path: prefer command line arg, then env var
+        compile_commands = args.compile_commands or os.environ.get(
+            "COMPILE_COMMANDS_PATH"
+        )
+        if not compile_commands:
+            print(
+                "Error: compile_commands.json path not specified. Use --compile-commands argument or set COMPILE_COMMANDS_PATH environment variable",
+                file=sys.stderr,
+            )
+            return 1
+
+        if not os.path.exists(compile_commands):
+            print(
+                f"Error: compile_commands.json not found at {compile_commands}",
+                file=sys.stderr,
+            )
+            return 1
+
+        # Get changed files
+        changed_files = self.get_changed_files(args)
+        if not changed_files:
+            if args.verbose:
+                print("No files changed, skipping ids check", file=sys.stderr)
+            return 0
+
+        # Process each category
+        any_processed = False
+        for category in self.FILE_CATEGORIES:
+            filtered_files = self.filter_files_for_category(changed_files, category)
+            if filtered_files:
+                any_processed = True
+                if not self.run_idt_on_files(
+                    filtered_files, category, args, idt_path, compile_commands
+                ):
+                    return 1
+
+        if not any_processed:
+            if args.verbose:
+                print(
+                    "No relevant header files changed, skipping ids check",
+                    file=sys.stderr,
+                )
+            return 0
+
+        # Check for differences
+        diff = self.check_for_diff()
+        should_update_gh = args.token and args.repo
+
+        if diff:
+            if should_update_gh:
+                comment_text = self.pr_comment_text_for_diff(diff)
+                self.update_pr(comment_text, args, create_new=True)
+            else:
+                print(
+                    "\nError: idt found missing LLVM_ABI annotations", file=sys.stderr
+                )
+                print(
+                    "Apply the following diff to fix the LLVM_ABI annotations:\n",
+                    file=sys.stderr,
+                )
+                print(diff)
+            return 1
+        else:
+            if should_update_gh:
+                comment_text = (
+                    ":white_check_mark: With the latest revision "
+                    f"this PR passed the {self.friendly_name}."
+                )
+                self.update_pr(comment_text, args, create_new=False)
+            if args.verbose:
+                print("All files pass ids check", file=sys.stderr)
+            return 0
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(
+        description="Check LLVM ABI annotations in header files"
+    )
+    parser.add_argument("--token", type=str, help="GitHub authentication token")
+    parser.add_argument(
+        "--repo",
+        type=str,
+        default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"),
+        help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)",
+    )
+    parser.add_argument("--issue-number", type=int, help="GitHub issue/PR number")
+    parser.add_argument(
+        "--start-rev",
+        type=str,
+        required=True,
+        help="Compute changes from this revision",
+    )
+    parser.add_argument(
+        "--end-rev",
+        type=str,
+        required=True,
+        help="Compute changes to this revision",
+    )
+    parser.add_argument(
+        "--changed-files",
+        type=str,
+        help="Comma separated list of files that have been changed",
+    )
+    parser.add_argument(
+        "--idt-path",
+        type=str,
+        help="Path to the idt executable (can also be set via IDT_PATH environment variable)",
+    )
+    parser.add_argument(
+        "--compile-commands",
+        type=str,
+        help="Path to compile_commands.json (can also be set via COMPILE_COMMANDS_PATH environment variable)",
+    )
+    parser.add_argument(
+        "--write-comment-to-file",
+        action="store_true",
+        help="Don't post comments on the PR, instead write the comments and metadata a file called 'comment'",
+    )
+    parser.add_argument(
+        "--verbose", action="store_true", default=True, help="Enable verbose output"
+    )
+
+    parsed_args = parser.parse_args()
+
+    # Parse changed files if provided
+    if parsed_args.changed_files:
+        parsed_args.changed_files = [
+            f.strip() for f in parsed_args.changed_files.split(",") if f.strip()
+        ]
+    else:
+        parsed_args.changed_files = []
+
+    args = IdsCheckArgs(parsed_args)
+    checker = IdsChecker()
+    exit_code = checker.run(args)
+
+    if checker.comment:
+        with open("comment", "w") as f:
+            import json
+
+            json.dump([checker.comment], f)
+
+    sys.exit(exit_code)



More information about the llvm-commits mailing list