[llvm-branch-commits] [clang] [llvm] [clang][ssaf] Add clang-ssaf-report-merge (PR #232546)

Jan Korous via llvm-branch-commits llvm-branch-commits at lists.llvm.org
Thu Oct 15 16:55:27 PDT 2026


https://github.com/jkorous-apple created https://github.com/llvm/llvm-project/pull/232546

A source transformation writes a SARIF report for each translation unit.
Add a tool that merges the reports of the translation units of a link
unit into one, the way clang-ssaf-src-edit-merge merges their edits.

Each report numbers its rules and artifacts on its own, so the tool
renumbers every result against the merged run before it drops the ones
that are identical, such as a finding in a header that several
translation units include. clang lists a run's artifacts in URI order
but numbers them in the order it first refers to them, so an artifact is
known by its own index. The inputs must come from the same
transformation, and a merged report can be merged again.

This relands 07195e29cdba (#231664), reverted with its stack in
782610a701b4 (#232274), under a shorter name:
clang-ssaf-merge-transformation-report took Windows build paths past
MAX_PATH. The GN build files come from 8a96bd506570 (#232114).

Co-authored-by: Kazu Hirata <kazu at google.com>
Assisted-by: Claude



>From 89f55de446980139d0205c70e07d7d5abb302727 Mon Sep 17 00:00:00 2001
From: Jan Korous <jkorous at apple.com>
Date: Thu, 15 Oct 2026 14:30:57 -0700
Subject: [PATCH] [clang][ssaf] Add clang-ssaf-report-merge

A source transformation writes a SARIF report for each translation unit.
Add a tool that merges the reports of the translation units of a link
unit into one, the way clang-ssaf-src-edit-merge merges their edits.

Each report numbers its rules and artifacts on its own, so the tool
renumbers every result against the merged run before it drops the ones
that are identical, such as a finding in a header that several
translation units include. clang lists a run's artifacts in URI order
but numbers them in the order it first refers to them, so an artifact is
known by its own index. The inputs must come from the same
transformation, and a merged report can be merged again.

This relands 07195e29cdba (#231664), reverted with its stack in
782610a701b4 (#232274), under a shorter name:
clang-ssaf-merge-transformation-report took Windows build paths past
MAX_PATH. The GN build files come from 8a96bd506570 (#232114).

Co-authored-by: Kazu Hirata <kazu at google.com>
Assisted-by: Claude
---
 .../user-docs/SourceEditGeneration.md         |  15 +
 .../Inputs/bad-artifact-index.sarif           |  71 ++++
 .../Inputs/bad-rule-index.sarif               |  71 ++++
 .../ssaf-report-merge/Inputs/empty.sarif      |  21 +
 .../Inputs/other-transformation.sarif         |  21 +
 .../ssaf-report-merge/Inputs/tu-a.sarif       | 106 +++++
 .../ssaf-report-merge/Inputs/tu-b.sarif       | 118 ++++++
 .../ssaf-report-merge/Inputs/tu-c.sarif       |  71 ++++
 .../Inputs/tu-clang-order.sarif               | 124 ++++++
 .../ssaf-report-merge/Inputs/two-runs.sarif   |  36 ++
 .../clang-artifact-order.test                 |  23 ++
 .../ssaf-report-merge/cli-errors.test         |  87 ++++
 .../Scalable/ssaf-report-merge/empty.test     |  16 +
 .../Scalable/ssaf-report-merge/help.test      |  19 +
 .../Scalable/ssaf-report-merge/merge.test     |  51 +++
 .../Scalable/ssaf-report-merge/nested.test    |  16 +
 .../ssaf-report-merge/order-invariance.test   |   8 +
 .../ssaf-report-merge/plugin-reports.test     |  47 +++
 .../Scalable/ssaf-report-merge/version.test   |   5 +
 clang/test/CMakeLists.txt                     |   1 +
 clang/tools/CMakeLists.txt                    |   1 +
 .../clang-ssaf-report-merge/CMakeLists.txt    |  12 +
 .../clang-ssaf-report-merge/ReportMerge.cpp   | 384 ++++++++++++++++++
 llvm/utils/gn/secondary/clang/test/BUILD.gn   |   1 +
 .../tools/clang-ssaf-report-merge/BUILD.gn    |   8 +
 25 files changed, 1333 insertions(+)
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/bad-artifact-index.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/bad-rule-index.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/empty.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/other-transformation.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-a.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-b.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-c.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-clang-order.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/two-runs.sarif
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/clang-artifact-order.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/cli-errors.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/empty.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/help.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/merge.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/nested.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/order-invariance.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/plugin-reports.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-report-merge/version.test
 create mode 100644 clang/tools/clang-ssaf-report-merge/CMakeLists.txt
 create mode 100644 clang/tools/clang-ssaf-report-merge/ReportMerge.cpp
 create mode 100644 llvm/utils/gn/secondary/clang/tools/clang-ssaf-report-merge/BUILD.gn

diff --git a/clang/docs/ScalableStaticAnalysis/user-docs/SourceEditGeneration.md b/clang/docs/ScalableStaticAnalysis/user-docs/SourceEditGeneration.md
index af7fc7f56aa8ca..fb080d7d9d6183 100644
--- a/clang/docs/ScalableStaticAnalysis/user-docs/SourceEditGeneration.md
+++ b/clang/docs/ScalableStaticAnalysis/user-docs/SourceEditGeneration.md
@@ -65,3 +65,18 @@ $ clang-apply-replacements --remove-change-desc-files <dir-with-yaml>
 
 The transformation report can be consumed by any SARIF viewer.
 
+
+## Merging transformation reports
+
+A transformation report covers one translation unit. `clang-ssaf-report-merge`
+merges the reports of the translation units of a link unit into one:
+
+```console
+$ clang-ssaf-report-merge foo.sarif bar.sarif -o prg.sarif
+```
+
+The inputs must come from the same transformation. A finding that several
+inputs share, such as one in a header that both translation units include,
+is reported once, and the result does not depend on the order of the
+inputs. A merged report can be merged again.
+
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/bad-artifact-index.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/bad-artifact-index.sarif
new file mode 100644
index 00000000000000..893cd17a4a1b5e
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/bad-artifact-index.sarif
@@ -0,0 +1,71 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [
+        {
+          "length": 120,
+          "location": {
+            "index": 0,
+            "uri": "file:///src/a.cpp"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        }
+      ],
+      "columnKind": "unicodeCodePoints",
+      "results": [
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 5,
+                  "uri": "file:///src/a.cpp"
+                },
+                "region": {
+                  "endColumn": 5,
+                  "endLine": 1,
+                  "startColumn": 1,
+                  "startLine": 1
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "declarator spelled through a macro is not yet rewritten"
+          },
+          "ruleId": "cpp-bounded-buffers-skipped",
+          "ruleIndex": 0
+        }
+      ],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "enabled": true,
+                "level": "warning",
+                "rank": -1
+              },
+              "fullDescription": {
+                "text": ""
+              },
+              "id": "cpp-bounded-buffers-skipped",
+              "name": ""
+            }
+          ],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/bad-rule-index.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/bad-rule-index.sarif
new file mode 100644
index 00000000000000..093a89d4679239
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/bad-rule-index.sarif
@@ -0,0 +1,71 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [
+        {
+          "length": 120,
+          "location": {
+            "index": 0,
+            "uri": "file:///src/a.cpp"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        }
+      ],
+      "columnKind": "unicodeCodePoints",
+      "results": [
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 0,
+                  "uri": "file:///src/a.cpp"
+                },
+                "region": {
+                  "endColumn": 5,
+                  "endLine": 1,
+                  "startColumn": 1,
+                  "startLine": 1
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "declarator spelled through a macro is not yet rewritten"
+          },
+          "ruleId": "cpp-bounded-buffers-skipped",
+          "ruleIndex": 3
+        }
+      ],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "enabled": true,
+                "level": "warning",
+                "rank": -1
+              },
+              "fullDescription": {
+                "text": ""
+              },
+              "id": "cpp-bounded-buffers-skipped",
+              "name": ""
+            }
+          ],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/empty.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/empty.sarif
new file mode 100644
index 00000000000000..944201f0be593f
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/empty.sarif
@@ -0,0 +1,21 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [],
+      "columnKind": "unicodeCodePoints",
+      "results": [],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/other-transformation.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/other-transformation.sarif
new file mode 100644
index 00000000000000..2a950fd626de57
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/other-transformation.sarif
@@ -0,0 +1,21 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [],
+      "columnKind": "unicodeCodePoints",
+      "results": [],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (fix-noexcept)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-a.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-a.sarif
new file mode 100644
index 00000000000000..24a766b14c6050
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-a.sarif
@@ -0,0 +1,106 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [
+        {
+          "length": 120,
+          "location": {
+            "index": 0,
+            "uri": "file:///src/a.cpp"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        },
+        {
+          "length": 60,
+          "location": {
+            "index": 1,
+            "uri": "file:///src/shared.h"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        }
+      ],
+      "columnKind": "unicodeCodePoints",
+      "results": [
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 1,
+                  "uri": "file:///src/shared.h"
+                },
+                "region": {
+                  "endColumn": 12,
+                  "endLine": 3,
+                  "startColumn": 8,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "multi-level pointer indirection is not yet rewritten"
+          },
+          "ruleId": "cpp-bounded-buffers-skipped",
+          "ruleIndex": 0
+        },
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 0,
+                  "uri": "file:///src/a.cpp"
+                },
+                "region": {
+                  "endColumn": 7,
+                  "endLine": 7,
+                  "startColumn": 3,
+                  "startLine": 7
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "declarator spelled through a macro is not yet rewritten"
+          },
+          "ruleId": "cpp-bounded-buffers-skipped",
+          "ruleIndex": 0
+        }
+      ],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "enabled": true,
+                "level": "warning",
+                "rank": -1
+              },
+              "fullDescription": {
+                "text": ""
+              },
+              "id": "cpp-bounded-buffers-skipped",
+              "name": ""
+            }
+          ],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-b.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-b.sarif
new file mode 100644
index 00000000000000..40d17d8736758c
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-b.sarif
@@ -0,0 +1,118 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [
+        {
+          "length": 60,
+          "location": {
+            "index": 0,
+            "uri": "file:///src/shared.h"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        },
+        {
+          "length": 90,
+          "location": {
+            "index": 1,
+            "uri": "file:///src/b.cpp"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        }
+      ],
+      "columnKind": "unicodeCodePoints",
+      "results": [
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 0,
+                  "uri": "file:///src/shared.h"
+                },
+                "region": {
+                  "endColumn": 12,
+                  "endLine": 3,
+                  "startColumn": 8,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "multi-level pointer indirection is not yet rewritten"
+          },
+          "ruleId": "cpp-bounded-buffers-skipped",
+          "ruleIndex": 1
+        },
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 1,
+                  "uri": "file:///src/b.cpp"
+                },
+                "region": {
+                  "endColumn": 5,
+                  "endLine": 5,
+                  "startColumn": 1,
+                  "startLine": 5
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "other finding"
+          },
+          "ruleId": "cpp-bounded-buffers-other",
+          "ruleIndex": 0
+        }
+      ],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "enabled": true,
+                "level": "warning",
+                "rank": -1
+              },
+              "fullDescription": {
+                "text": ""
+              },
+              "id": "cpp-bounded-buffers-other",
+              "name": ""
+            },
+            {
+              "defaultConfiguration": {
+                "enabled": true,
+                "level": "warning",
+                "rank": -1
+              },
+              "fullDescription": {
+                "text": ""
+              },
+              "id": "cpp-bounded-buffers-skipped",
+              "name": ""
+            }
+          ],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-c.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-c.sarif
new file mode 100644
index 00000000000000..51086f43164e99
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-c.sarif
@@ -0,0 +1,71 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [
+        {
+          "length": 40,
+          "location": {
+            "index": 0,
+            "uri": "file:///src/c.cpp"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        }
+      ],
+      "columnKind": "unicodeCodePoints",
+      "results": [
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 0,
+                  "uri": "file:///src/c.cpp"
+                },
+                "region": {
+                  "endColumn": 9,
+                  "endLine": 2,
+                  "startColumn": 5,
+                  "startLine": 2
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "multi-level pointer indirection is not yet rewritten"
+          },
+          "ruleId": "cpp-bounded-buffers-skipped",
+          "ruleIndex": 0
+        }
+      ],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "enabled": true,
+                "level": "warning",
+                "rank": -1
+              },
+              "fullDescription": {
+                "text": ""
+              },
+              "id": "cpp-bounded-buffers-skipped",
+              "name": ""
+            }
+          ],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-clang-order.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-clang-order.sarif
new file mode 100644
index 00000000000000..8124ebbd875cc5
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/tu-clang-order.sarif
@@ -0,0 +1,124 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [
+        {
+          "length": 120,
+          "location": {
+            "index": 1,
+            "uri": "file:///src/a.cpp"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        },
+        {
+          "length": 60,
+          "location": {
+            "index": 0,
+            "uri": "file:///src/shared.h"
+          },
+          "mimeType": "text/plain",
+          "roles": [
+            "resultFile"
+          ]
+        }
+      ],
+      "columnKind": "unicodeCodePoints",
+      "results": [
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 0
+                },
+                "region": {
+                  "endColumn": 12,
+                  "endLine": 3,
+                  "startColumn": 8,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "multi-level pointer indirection is not yet rewritten"
+          },
+          "ruleId": "cpp-bounded-buffers-skipped",
+          "ruleIndex": 0
+        },
+        {
+          "level": "note",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 1,
+                  "uri": "file:///src/a.cpp"
+                },
+                "region": {
+                  "endColumn": 7,
+                  "endLine": 7,
+                  "startColumn": 3,
+                  "startLine": 7
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "declarator spelled through a macro is not yet rewritten"
+          },
+          "relatedLocations": [
+            {
+              "id": 1,
+              "message": {
+                "text": "the macro"
+              },
+              "physicalLocation": {
+                "artifactLocation": {
+                  "index": 0
+                },
+                "region": {
+                  "endColumn": 5,
+                  "endLine": 2,
+                  "startColumn": 1,
+                  "startLine": 2
+                }
+              }
+            }
+          ],
+          "ruleId": "cpp-bounded-buffers-skipped",
+          "ruleIndex": 0
+        }
+      ],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "enabled": true,
+                "level": "warning",
+                "rank": -1
+              },
+              "fullDescription": {
+                "text": ""
+              },
+              "id": "cpp-bounded-buffers-skipped",
+              "name": ""
+            }
+          ],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/two-runs.sarif b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/two-runs.sarif
new file mode 100644
index 00000000000000..6fc4646bfe1b73
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/Inputs/two-runs.sarif
@@ -0,0 +1,36 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "artifacts": [],
+      "columnKind": "unicodeCodePoints",
+      "results": [],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [],
+          "version": "24.0.0git"
+        }
+      }
+    },
+    {
+      "artifacts": [],
+      "columnKind": "unicodeCodePoints",
+      "results": [],
+      "tool": {
+        "driver": {
+          "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+          "informationUri": "https://clang.llvm.org/docs/UsersManual.html",
+          "language": "en-US",
+          "name": "clang-ssaf",
+          "rules": [],
+          "version": "24.0.0git"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/clang-artifact-order.test b/clang/test/Analysis/Scalable/ssaf-report-merge/clang-artifact-order.test
new file mode 100644
index 00000000000000..5a5342267e8c26
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/clang-artifact-order.test
@@ -0,0 +1,23 @@
+// clang lists a run's artifacts in URI order but numbers them in the order
+// they were first referred to, and its results refer to them by that number.
+// A location that names its artifact only by number, in a result or in a
+// related location, is placed by the artifact's own index.
+
+// RUN: rm -rf %t && mkdir -p %t
+// RUN: clang-ssaf-report-merge %S/Inputs/tu-clang-order.sarif \
+// RUN:   %S/Inputs/tu-b.sarif -o %t/merged.sarif
+// RUN: FileCheck %s --input-file=%t/merged.sarif
+// RUN: grep 'multi-level pointer indirection' %t/merged.sarif | count 1
+
+// CHECK:      "results": [
+// CHECK:        "index": 0,
+// CHECK-NEXT:   "uri": "file:///src/a.cpp"
+// CHECK:        "text": "declarator spelled through a macro is not yet rewritten"
+// CHECK:      "relatedLocations": [
+// CHECK:        "index": 2,
+// CHECK-NEXT:   "uri": "file:///src/shared.h"
+// CHECK:        "index": 1,
+// CHECK-NEXT:   "uri": "file:///src/b.cpp"
+// CHECK:        "index": 2,
+// CHECK-NEXT:   "uri": "file:///src/shared.h"
+// CHECK:        "text": "multi-level pointer indirection is not yet rewritten"
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/cli-errors.test b/clang/test/Analysis/Scalable/ssaf-report-merge/cli-errors.test
new file mode 100644
index 00000000000000..21ad71e1d4d4bd
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/cli-errors.test
@@ -0,0 +1,87 @@
+// Tests for clang-ssaf-report-merge errors.
+
+// RUN: rm -rf %t
+// RUN: mkdir -p %t
+
+// ============================================================================
+// CLI error: no arguments
+// ============================================================================
+
+// RUN: not clang-ssaf-report-merge 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=NO-ARGS
+// NO-ARGS: clang-ssaf-report-merge{{(\.exe)?}}: Not enough positional command line arguments specified!
+
+// ============================================================================
+// CLI error: missing -o
+// ============================================================================
+
+// RUN: not clang-ssaf-report-merge %S/Inputs/tu-a.sarif 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=NO-OUTPUT
+// NO-OUTPUT: clang-ssaf-report-merge{{(\.exe)?}}: for the -o option: must be specified at least once!
+
+// ============================================================================
+// Error: nonexistent input
+// ============================================================================
+
+// RUN: not clang-ssaf-report-merge %t/missing.sarif \
+// RUN:   -o %t/out.sarif 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=MISSING -DPATH=%t/missing.sarif
+// MISSING: clang-ssaf-report-merge: error: failed to read '[[PATH]]': {{.*}}
+
+// ============================================================================
+// Error: input is not JSON
+// ============================================================================
+
+// RUN: echo 'not json' > %t/not-json.sarif
+// RUN: not clang-ssaf-report-merge %t/not-json.sarif \
+// RUN:   -o %t/out.sarif 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=NOT-JSON -DPATH=%t/not-json.sarif
+// NOT-JSON: clang-ssaf-report-merge: error: failed to parse '[[PATH]]': {{.*}}
+
+// ============================================================================
+// Error: input does not have exactly one run
+// ============================================================================
+
+// RUN: not clang-ssaf-report-merge %S/Inputs/two-runs.sarif \
+// RUN:   -o %t/out.sarif 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=TWO-RUNS
+// TWO-RUNS: clang-ssaf-report-merge: error: '{{.*}}two-runs.sarif' is not a SARIF log with exactly one run
+
+// ============================================================================
+// Error: inputs from different transformations
+// ============================================================================
+
+// RUN: not clang-ssaf-report-merge %S/Inputs/tu-a.sarif \
+// RUN:   %S/Inputs/other-transformation.sarif -o %t/out.sarif 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=OTHER
+// OTHER: clang-ssaf-report-merge: error: '{{.*}}other-transformation.sarif' reports on 'clang ScalableStaticAnalysisFramework source transformation (fix-noexcept)', not on 'clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)'
+
+// ============================================================================
+// Error: a result refers to a rule its run does not define
+// ============================================================================
+
+// RUN: not clang-ssaf-report-merge \
+// RUN:   %S/Inputs/bad-rule-index.sarif -o %t/out.sarif 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=BAD-RULE
+// BAD-RULE: clang-ssaf-report-merge: error: '{{.*}}bad-rule-index.sarif': a result refers to rule 3, which its run does not define
+
+// ============================================================================
+// Error: a location refers to an artifact its run does not define
+// ============================================================================
+
+// RUN: not clang-ssaf-report-merge \
+// RUN:   %S/Inputs/bad-artifact-index.sarif -o %t/out.sarif 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=BAD-ARTIFACT
+// BAD-ARTIFACT: clang-ssaf-report-merge: error: '{{.*}}bad-artifact-index.sarif': a location refers to artifact 5, which its run does not define
+
+// ============================================================================
+// Error: the output cannot be written
+// ============================================================================
+
+// RUN: not clang-ssaf-report-merge %S/Inputs/tu-a.sarif \
+// RUN:   -o %t/no-such-dir/out.sarif 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=CANNOT-WRITE -DPATH=%t/no-such-dir/out.sarif
+// CANNOT-WRITE: clang-ssaf-report-merge: error: failed to write '[[PATH]]': {{.*}}
+
+// No error leaves an output behind.
+// RUN: not ls %t/out.sarif
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/empty.test b/clang/test/Analysis/Scalable/ssaf-report-merge/empty.test
new file mode 100644
index 00000000000000..afba9351c83a17
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/empty.test
@@ -0,0 +1,16 @@
+// Reports without results merge into a report without results, and add
+// nothing to a report that has some.
+
+// RUN: rm -rf %t && mkdir -p %t
+// RUN: clang-ssaf-report-merge %S/Inputs/empty.sarif \
+// RUN:   %S/Inputs/empty.sarif -o %t/empty.sarif
+// RUN: FileCheck %s --input-file=%t/empty.sarif
+// CHECK: "artifacts": [],
+// CHECK: "results": [],
+// CHECK: "rules": [],
+
+// RUN: clang-ssaf-report-merge %S/Inputs/tu-a.sarif \
+// RUN:   -o %t/a.sarif
+// RUN: clang-ssaf-report-merge %S/Inputs/empty.sarif \
+// RUN:   %S/Inputs/tu-a.sarif -o %t/empty-a.sarif
+// RUN: diff %t/a.sarif %t/empty-a.sarif
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/help.test b/clang/test/Analysis/Scalable/ssaf-report-merge/help.test
new file mode 100644
index 00000000000000..af68f1a11b2cdb
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/help.test
@@ -0,0 +1,19 @@
+// Test clang-ssaf-report-merge help option
+
+// RUN: clang-ssaf-report-merge --help-list-hidden \
+// RUN:           | FileCheck %s --match-full-lines
+
+// CHECK:      OVERVIEW: SSAF Transformation Report Merger
+// CHECK-EMPTY:
+// CHECK-NEXT: USAGE: clang-ssaf-report-merge{{(\.exe)?}} [options] <input.sarif>...
+// CHECK-EMPTY:
+// CHECK-NEXT: OPTIONS:
+// CHECK-NEXT:   -h                  - Alias for --help
+// CHECK-NEXT:   --help              - Display available options (--help-hidden for more)
+// CHECK-NEXT:   --help-hidden       - Display all available options
+// CHECK-NEXT:   --help-list         - Display list of available options (--help-list-hidden for more)
+// CHECK-NEXT:   --help-list-hidden  - Display list of all available options
+// CHECK-NEXT:   -o <path>           - Output file path
+// CHECK-NEXT:   --print-all-options - Print all option values after command line parsing
+// CHECK-NEXT:   --print-options     - Print non-default options after command line parsing
+// CHECK-NEXT:   --version           - Display the version of this program
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/merge.test b/clang/test/Analysis/Scalable/ssaf-report-merge/merge.test
new file mode 100644
index 00000000000000..dfbd663e5ad1fe
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/merge.test
@@ -0,0 +1,51 @@
+// Two reports that both carry a finding in a shared header, under different
+// rule and artifact indices, merge into one run.
+
+// RUN: rm -rf %t && mkdir -p %t
+// RUN: clang-ssaf-report-merge %S/Inputs/tu-a.sarif \
+// RUN:   %S/Inputs/tu-b.sarif -o %t/merged.sarif
+// RUN: FileCheck %s --input-file=%t/merged.sarif
+
+// '-' writes the report to stdout.
+// RUN: clang-ssaf-report-merge %S/Inputs/tu-a.sarif \
+// RUN:   %S/Inputs/tu-b.sarif -o - > %t/stdout.sarif
+// RUN: diff %t/merged.sarif %t/stdout.sarif
+
+// The artifacts of both runs, once each and numbered in URI order.
+// CHECK:      "artifacts": [
+// CHECK:        "index": 0,
+// CHECK-NEXT:   "uri": "file:///src/a.cpp"
+// CHECK:        "index": 1,
+// CHECK-NEXT:   "uri": "file:///src/b.cpp"
+// CHECK:        "index": 2,
+// CHECK-NEXT:   "uri": "file:///src/shared.h"
+
+// The results in location order, each pointing into the merged rules and
+// artifacts. The header's finding is kept once.
+// CHECK:      "results": [
+// CHECK:        "index": 0,
+// CHECK-NEXT:   "uri": "file:///src/a.cpp"
+// CHECK:        "ruleId": "cpp-bounded-buffers-skipped",
+// CHECK-NEXT:   "ruleIndex": 1
+// CHECK:        "index": 1,
+// CHECK-NEXT:   "uri": "file:///src/b.cpp"
+// CHECK:        "ruleId": "cpp-bounded-buffers-other",
+// CHECK-NEXT:   "ruleIndex": 0
+// CHECK:        "index": 2,
+// CHECK-NEXT:   "uri": "file:///src/shared.h"
+// CHECK:        "text": "multi-level pointer indirection is not yet rewritten"
+// CHECK:        "ruleId": "cpp-bounded-buffers-skipped",
+// CHECK-NEXT:   "ruleIndex": 1
+// CHECK-NOT:    "text": "multi-level pointer indirection is not yet rewritten"
+
+// The rules of both runs, once each and in id order, under the driver of the
+// first input.
+// CHECK:      "tool": {
+// CHECK:        "fullName": "clang ScalableStaticAnalysisFramework source transformation (cpp-bounded-buffers)",
+// CHECK:        "name": "clang-ssaf",
+// CHECK:        "rules": [
+// CHECK:          "id": "cpp-bounded-buffers-other",
+// CHECK:          "id": "cpp-bounded-buffers-skipped",
+// CHECK-NOT:      "id":
+// CHECK:        "version": "24.0.0git"
+// CHECK:      "version": "2.1.0"
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/nested.test b/clang/test/Analysis/Scalable/ssaf-report-merge/nested.test
new file mode 100644
index 00000000000000..6e1a5a1d7ca4b1
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/nested.test
@@ -0,0 +1,16 @@
+// A merged report merges again like the reports it was made from, so that an
+// archive's report can feed the report of the program it is linked into.
+
+// RUN: rm -rf %t && mkdir -p %t
+// RUN: clang-ssaf-report-merge %S/Inputs/tu-a.sarif \
+// RUN:   %S/Inputs/tu-b.sarif -o %t/ab.sarif
+// RUN: clang-ssaf-report-merge %t/ab.sarif \
+// RUN:   %S/Inputs/tu-c.sarif -o %t/ab-c.sarif
+// RUN: clang-ssaf-report-merge %S/Inputs/tu-a.sarif \
+// RUN:   %S/Inputs/tu-b.sarif %S/Inputs/tu-c.sarif -o %t/abc.sarif
+// RUN: diff %t/ab-c.sarif %t/abc.sarif
+
+// Merging a report with itself adds nothing.
+// RUN: clang-ssaf-report-merge %t/ab.sarif %t/ab.sarif \
+// RUN:   -o %t/ab-ab.sarif
+// RUN: diff %t/ab.sarif %t/ab-ab.sarif
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/order-invariance.test b/clang/test/Analysis/Scalable/ssaf-report-merge/order-invariance.test
new file mode 100644
index 00000000000000..62ca25eb5c48ce
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/order-invariance.test
@@ -0,0 +1,8 @@
+// The merged report does not depend on the order of the inputs.
+
+// RUN: rm -rf %t && mkdir -p %t
+// RUN: clang-ssaf-report-merge %S/Inputs/tu-a.sarif \
+// RUN:   %S/Inputs/tu-b.sarif %S/Inputs/tu-c.sarif -o %t/abc.sarif
+// RUN: clang-ssaf-report-merge %S/Inputs/tu-c.sarif \
+// RUN:   %S/Inputs/tu-b.sarif %S/Inputs/tu-a.sarif -o %t/cba.sarif
+// RUN: diff %t/abc.sarif %t/cba.sarif
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/plugin-reports.test b/clang/test/Analysis/Scalable/ssaf-report-merge/plugin-reports.test
new file mode 100644
index 00000000000000..a5d525cb74f3f4
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/plugin-reports.test
@@ -0,0 +1,47 @@
+// Reports written by clang for two translation units merge into one, and a
+// report given twice contributes its findings once.
+
+// REQUIRES: plugins
+
+// RUN: rm -rf %t && split-file %s %t
+// DEFINE: %{transform} = %clang_cc1 \
+// DEFINE:   -load %llvmshlibdir/SSAFTestTransformationPlugin%pluginext \
+// DEFINE:   --ssaf-source-transformation=test-transformation \
+// DEFINE:   --ssaf-global-scope-analysis-result=%S/../source-edit-generation/Inputs/two-function-suite.json \
+// DEFINE:   --ssaf-link-unit-id=lu -emit-obj
+// RUN: %{transform} --ssaf-compilation-unit-id=a --ssaf-src-edit-file=%t/a.yaml \
+// RUN:   --ssaf-transformation-report-file=%t/a.sarif -o %t/a.o %t/a.cpp
+// RUN: %{transform} --ssaf-compilation-unit-id=b --ssaf-src-edit-file=%t/b.yaml \
+// RUN:   --ssaf-transformation-report-file=%t/b.sarif -o %t/b.o %t/b.cpp
+
+// RUN: clang-ssaf-report-merge %t/a.sarif %t/b.sarif \
+// RUN:   %t/a.sarif -o %t/merged.sarif
+// RUN: FileCheck %s --input-file=%t/merged.sarif
+// RUN: grep '"text": "visited alpha"' %t/merged.sarif | count 1
+
+// CHECK:      "artifacts": [
+// CHECK:        "index": 0,
+// CHECK-NEXT:   "uri": "file://{{.*}}a.cpp"
+// CHECK:        "index": 1,
+// CHECK-NEXT:   "uri": "file://{{.*}}b.cpp"
+// CHECK:      "results": [
+// CHECK:        "index": 0,
+// CHECK-NEXT:   "uri": "file://{{.*}}a.cpp"
+// CHECK:        "text": "visited alpha"
+// CHECK:        "ruleId": "test-touches-function",
+// CHECK-NEXT:   "ruleIndex": 0
+// CHECK:        "index": 1,
+// CHECK-NEXT:   "uri": "file://{{.*}}b.cpp"
+// CHECK:        "text": "visited beta"
+// CHECK:        "ruleId": "test-touches-function",
+// CHECK-NEXT:   "ruleIndex": 0
+// CHECK:        "name": "clang-ssaf",
+// CHECK:        "rules": [
+// CHECK:          "id": "test-touches-function",
+// CHECK-NOT:      "id":
+
+//--- a.cpp
+void alpha() {}
+
+//--- b.cpp
+void beta() {}
diff --git a/clang/test/Analysis/Scalable/ssaf-report-merge/version.test b/clang/test/Analysis/Scalable/ssaf-report-merge/version.test
new file mode 100644
index 00000000000000..7a94edf6d0dcf8
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-report-merge/version.test
@@ -0,0 +1,5 @@
+// Test clang-ssaf-report-merge version
+
+// RUN: clang-ssaf-report-merge --version | FileCheck %s
+
+// CHECK: clang-ssaf-report-merge 0.1
diff --git a/clang/test/CMakeLists.txt b/clang/test/CMakeLists.txt
index 72a4f2760df844..943035a21f0afd 100644
--- a/clang/test/CMakeLists.txt
+++ b/clang/test/CMakeLists.txt
@@ -107,6 +107,7 @@ list(APPEND CLANG_TEST_DEPS
   clang-ssaf-analyzer
   clang-ssaf-format
   clang-ssaf-linker
+  clang-ssaf-report-merge
   clang-ssaf-src-edit-merge
   clang-sycl-linker
   diagtool
diff --git a/clang/tools/CMakeLists.txt b/clang/tools/CMakeLists.txt
index 182489653b8055..bad63b18d9633a 100644
--- a/clang/tools/CMakeLists.txt
+++ b/clang/tools/CMakeLists.txt
@@ -21,6 +21,7 @@ add_clang_subdirectory(clang-ssaf-linker)
 if(NOT WIN32)
   add_clang_subdirectory(clang-ssaf-orchestrator-make)
 endif()
+add_clang_subdirectory(clang-ssaf-report-merge)
 add_clang_subdirectory(clang-ssaf-src-edit-merge)
 add_clang_subdirectory(clang-sycl-linker)
 add_clang_subdirectory(clang-installapi)
diff --git a/clang/tools/clang-ssaf-report-merge/CMakeLists.txt b/clang/tools/clang-ssaf-report-merge/CMakeLists.txt
new file mode 100644
index 00000000000000..a001e5b720479b
--- /dev/null
+++ b/clang/tools/clang-ssaf-report-merge/CMakeLists.txt
@@ -0,0 +1,12 @@
+set(LLVM_LINK_COMPONENTS
+  Support
+  )
+
+add_clang_tool(clang-ssaf-report-merge
+  ReportMerge.cpp
+  )
+
+clang_target_link_libraries(clang-ssaf-report-merge
+  PRIVATE
+  clangScalableStaticAnalysisTool
+  )
diff --git a/clang/tools/clang-ssaf-report-merge/ReportMerge.cpp b/clang/tools/clang-ssaf-report-merge/ReportMerge.cpp
new file mode 100644
index 00000000000000..31dc5d890e9322
--- /dev/null
+++ b/clang/tools/clang-ssaf-report-merge/ReportMerge.cpp
@@ -0,0 +1,384 @@
+//===- ReportMerge.cpp - SSAF Report Merge Tool ---------------------------===//
+//
+// 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
+//
+//===----------------------------------------------------------------------===//
+//
+//  This file implements the tool that merges SARIF transformation reports, as
+//  written by clang's --ssaf-transformation-report-file or by this tool, into
+//  one report. Every input run numbers its rules and artifacts on its own, so
+//  results are renumbered against the merged run, and identical results, such
+//  as a finding in a header that several translation units include, are kept
+//  once. An artifact is numbered by its own location index where it has one,
+//  since clang lists artifacts in URI order but numbers them in the order they
+//  were first referred to.
+//
+//===----------------------------------------------------------------------===//
+
+#include "clang/ScalableStaticAnalysis/Tool/Utils.h"
+#include "llvm/ADT/ArrayRef.h"
+#include "llvm/ADT/STLExtras.h"
+#include "llvm/ADT/StringRef.h"
+#include "llvm/Support/CommandLine.h"
+#include "llvm/Support/FileSystem.h"
+#include "llvm/Support/FormatVariadic.h"
+#include "llvm/Support/InitLLVM.h"
+#include "llvm/Support/JSON.h"
+#include "llvm/Support/MemoryBuffer.h"
+#include "llvm/Support/ToolOutputFile.h"
+#include "llvm/Support/raw_ostream.h"
+#include <cstdint>
+#include <map>
+#include <optional>
+#include <set>
+#include <string>
+#include <tuple>
+#include <vector>
+
+using namespace llvm;
+using namespace clang::ssaf;
+
+namespace {
+
+//===----------------------------------------------------------------------===//
+// Command-Line Options
+//===----------------------------------------------------------------------===//
+
+cl::OptionCategory ReportMergeCategory("clang-ssaf-report-merge options");
+
+cl::list<std::string> InputPaths(cl::Positional, cl::desc("<input.sarif>..."),
+                                 cl::OneOrMore, cl::cat(ReportMergeCategory));
+
+cl::opt<std::string> OutputPath("o", cl::desc("Output file path"),
+                                cl::value_desc("path"), cl::Required,
+                                cl::cat(ReportMergeCategory));
+
+//===----------------------------------------------------------------------===//
+// Error Messages
+//===----------------------------------------------------------------------===//
+
+namespace ErrorMessages {
+
+constexpr const char *CannotRead = "failed to read '{0}': {1}";
+
+constexpr const char *CannotParse = "failed to parse '{0}': {1}";
+
+constexpr const char *NotOneRun =
+    "'{0}' is not a SARIF log with exactly one run";
+
+constexpr const char *OtherTransformation =
+    "'{0}' reports on '{1}', not on '{2}'";
+
+constexpr const char *UnknownRule =
+    "'{0}': a result refers to rule {1}, which its run does not define";
+
+constexpr const char *UnknownArtifact =
+    "'{0}': a location refers to artifact {1}, which its run does not define";
+
+constexpr const char *CannotWrite = "failed to write '{0}': {1}";
+
+} // namespace ErrorMessages
+
+//===----------------------------------------------------------------------===//
+// Reading and Writing
+//===----------------------------------------------------------------------===//
+
+json::Value readLog(StringRef Path) {
+  ErrorOr<std::unique_ptr<MemoryBuffer>> Buffer = MemoryBuffer::getFile(Path);
+  if (!Buffer) {
+    fail(ErrorMessages::CannotRead, Path, Buffer.getError().message());
+  }
+  Expected<json::Value> Log = json::parse((*Buffer)->getBuffer());
+  if (!Log) {
+    fail(ErrorMessages::CannotParse, Path, toString(Log.takeError()));
+  }
+  return std::move(*Log);
+}
+
+json::Object &getRun(json::Value &Log, StringRef Path) {
+  json::Object *Root = Log.getAsObject();
+  json::Array *Runs = Root ? Root->getArray("runs") : nullptr;
+  if (!Runs || Runs->size() != 1 || !Runs->front().getAsObject()) {
+    fail(ErrorMessages::NotOneRun, Path);
+  }
+  return *Runs->front().getAsObject();
+}
+
+void writeLog(const json::Value &Log, StringRef Path) {
+  std::error_code EC;
+  ToolOutputFile Out(Path, EC, sys::fs::OF_Text);
+  if (EC) {
+    fail(ErrorMessages::CannotWrite, Path, EC.message());
+  }
+  Out.os() << formatv("{0:2}", Log) << "\n";
+  Out.os().flush();
+  if (Out.os().has_error()) {
+    fail(ErrorMessages::CannotWrite, Path, Out.os().error().message());
+  }
+  Out.keep();
+}
+
+StringRef getString(const json::Object *Object, StringRef Key) {
+  if (!Object) {
+    return "";
+  }
+  return Object->getString(Key).value_or("");
+}
+
+json::Object *getArtifactLocation(json::Value &Location) {
+  json::Object *Object = Location.getAsObject();
+  json::Object *Physical =
+      Object ? Object->getObject("physicalLocation") : nullptr;
+  return Physical ? Physical->getObject("artifactLocation") : nullptr;
+}
+
+/// Returns a copy of \p Object without the members named in \p Excluded.
+json::Object copyExcept(const json::Object &Object,
+                        ArrayRef<StringRef> Excluded) {
+  json::Object Copy;
+  for (const auto &[Key, Value] : Object) {
+    if (!is_contained(Excluded, StringRef(Key))) {
+      Copy[Key] = Value;
+    }
+  }
+  return Copy;
+}
+
+//===----------------------------------------------------------------------===//
+// Merging
+//===----------------------------------------------------------------------===//
+
+/// A result whose rule and artifacts are named by id and URI rather than by
+/// index, so that results from different runs compare equal.
+struct Finding {
+  std::string Uri;
+  int64_t Line;
+  int64_t Column;
+  std::string RuleId;
+  std::string Message;
+  std::string Serialized;
+  json::Object Result;
+
+  bool operator<(const Finding &Other) const {
+    return std::tie(Uri, Line, Column, RuleId, Message, Serialized) <
+           std::tie(Other.Uri, Other.Line, Other.Column, Other.RuleId,
+                    Other.Message, Other.Serialized);
+  }
+};
+
+class ReportMerger {
+public:
+  void add(json::Value Log, StringRef Path);
+  json::Value takeMerged();
+
+private:
+  void addFinding(json::Object Result);
+
+  bool HasInput = false;
+  json::Object Root;
+  json::Object Run;
+  json::Object Tool;
+  json::Object Driver;
+  std::map<std::string, json::Value> Rules;
+  std::map<std::string, json::Value> Artifacts;
+  std::set<std::string> Seen;
+  std::vector<Finding> Findings;
+};
+
+void ReportMerger::add(json::Value Log, StringRef Path) {
+  json::Object &InputRun = getRun(Log, Path);
+  const json::Object *InputTool = InputRun.getObject("tool");
+  const json::Object *InputDriver =
+      InputTool ? InputTool->getObject("driver") : nullptr;
+
+  if (!HasInput) {
+    HasInput = true;
+    Root = copyExcept(*Log.getAsObject(), {"runs"});
+    Run = copyExcept(InputRun, {"artifacts", "results", "tool"});
+    if (InputTool) {
+      Tool = copyExcept(*InputTool, {"driver"});
+    }
+    if (InputDriver) {
+      Driver = copyExcept(*InputDriver, {"rules"});
+    }
+  } else if (getString(InputDriver, "name") != getString(&Driver, "name") ||
+             getString(InputDriver, "fullName") !=
+                 getString(&Driver, "fullName")) {
+    fail(ErrorMessages::OtherTransformation, Path,
+         getString(InputDriver, "fullName"), getString(&Driver, "fullName"));
+  }
+
+  std::vector<std::string> RuleIds;
+  if (const json::Array *InputRules =
+          InputDriver ? InputDriver->getArray("rules") : nullptr) {
+    for (const json::Value &Rule : *InputRules) {
+      std::string Id = getString(Rule.getAsObject(), "id").str();
+      Rules.try_emplace(Id, Rule);
+      RuleIds.push_back(std::move(Id));
+    }
+  }
+
+  std::map<int64_t, std::string> Uris;
+  if (const json::Array *InputArtifacts = InputRun.getArray("artifacts")) {
+    for (auto [Position, Artifact] : enumerate(*InputArtifacts)) {
+      const json::Object *Object = Artifact.getAsObject();
+      const json::Object *Location =
+          Object ? Object->getObject("location") : nullptr;
+      std::string Uri = getString(Location, "uri").str();
+      int64_t Index = static_cast<int64_t>(Position);
+      if (Location) {
+        Index = Location->getInteger("index").value_or(Index);
+      }
+      Artifacts.try_emplace(Uri, Artifact);
+      Uris[Index] = std::move(Uri);
+    }
+  }
+
+  json::Array *Results = InputRun.getArray("results");
+  if (!Results) {
+    return;
+  }
+  for (json::Value &Value : *Results) {
+    json::Object *Result = Value.getAsObject();
+    if (!Result) {
+      continue;
+    }
+    if (std::optional<int64_t> Index = Result->getInteger("ruleIndex")) {
+      if (*Index < 0 || *Index >= static_cast<int64_t>(RuleIds.size())) {
+        fail(ErrorMessages::UnknownRule, Path, *Index);
+      }
+      Result->try_emplace("ruleId", RuleIds[*Index]);
+      Result->erase("ruleIndex");
+    }
+    for (StringRef Key : {"locations", "relatedLocations"}) {
+      json::Array *Locations = Result->getArray(Key);
+      if (!Locations) {
+        continue;
+      }
+      for (json::Value &Location : *Locations) {
+        json::Object *Artifact = getArtifactLocation(Location);
+        if (!Artifact) {
+          continue;
+        }
+        if (std::optional<int64_t> Index = Artifact->getInteger("index")) {
+          auto Uri = Uris.find(*Index);
+          if (Uri == Uris.end()) {
+            fail(ErrorMessages::UnknownArtifact, Path, *Index);
+          }
+          Artifact->try_emplace("uri", Uri->second);
+          Artifact->erase("index");
+        }
+      }
+    }
+    addFinding(std::move(*Result));
+  }
+}
+
+void ReportMerger::addFinding(json::Object Result) {
+  json::Value Value(std::move(Result));
+  Finding F;
+  F.Serialized = formatv("{0}", Value);
+  if (!Seen.insert(F.Serialized).second) {
+    return;
+  }
+  json::Object &Object = *Value.getAsObject();
+  F.Line = 0;
+  F.Column = 0;
+  if (json::Array *Locations = Object.getArray("locations");
+      Locations && !Locations->empty()) {
+    F.Uri = getString(getArtifactLocation(Locations->front()), "uri").str();
+    const json::Object *Location = Locations->front().getAsObject();
+    const json::Object *Physical =
+        Location ? Location->getObject("physicalLocation") : nullptr;
+    if (const json::Object *Region =
+            Physical ? Physical->getObject("region") : nullptr) {
+      F.Line = Region->getInteger("startLine").value_or(0);
+      F.Column = Region->getInteger("startColumn").value_or(0);
+    }
+  }
+  F.RuleId = getString(&Object, "ruleId").str();
+  F.Message = getString(Object.getObject("message"), "text").str();
+  F.Result = std::move(Object);
+  Findings.push_back(std::move(F));
+}
+
+json::Value ReportMerger::takeMerged() {
+  std::map<std::string, int64_t> RuleIndex;
+  json::Array MergedRules;
+  for (auto &[Id, Rule] : Rules) {
+    RuleIndex[Id] = MergedRules.size();
+    MergedRules.push_back(std::move(Rule));
+  }
+
+  std::map<std::string, int64_t> ArtifactIndex;
+  json::Array MergedArtifacts;
+  for (auto &[Uri, Artifact] : Artifacts) {
+    int64_t Index = MergedArtifacts.size();
+    ArtifactIndex[Uri] = Index;
+    if (json::Object *Object = Artifact.getAsObject()) {
+      if (json::Object *Location = Object->getObject("location")) {
+        (*Location)["index"] = Index;
+      }
+    }
+    MergedArtifacts.push_back(std::move(Artifact));
+  }
+
+  llvm::sort(Findings);
+  json::Array MergedResults;
+  for (Finding &F : Findings) {
+    auto Rule = RuleIndex.find(F.RuleId);
+    if (Rule != RuleIndex.end()) {
+      F.Result["ruleIndex"] = Rule->second;
+    }
+    for (StringRef Key : {"locations", "relatedLocations"}) {
+      json::Array *Locations = F.Result.getArray(Key);
+      if (!Locations) {
+        continue;
+      }
+      for (json::Value &Location : *Locations) {
+        json::Object *Artifact = getArtifactLocation(Location);
+        if (!Artifact) {
+          continue;
+        }
+        auto Index = ArtifactIndex.find(getString(Artifact, "uri").str());
+        if (Index != ArtifactIndex.end()) {
+          (*Artifact)["index"] = Index->second;
+        }
+      }
+    }
+    MergedResults.push_back(std::move(F.Result));
+  }
+
+  Driver["rules"] = std::move(MergedRules);
+  Tool["driver"] = std::move(Driver);
+  Run["tool"] = std::move(Tool);
+  Run["artifacts"] = std::move(MergedArtifacts);
+  Run["results"] = std::move(MergedResults);
+  json::Array Runs;
+  Runs.push_back(std::move(Run));
+  Root["runs"] = std::move(Runs);
+  return std::move(Root);
+}
+
+} // namespace
+
+//===----------------------------------------------------------------------===//
+// Driver
+//===----------------------------------------------------------------------===//
+
+int main(int argc, const char **argv) {
+  llvm::StringRef ToolHeading = "SSAF Transformation Report Merger";
+
+  InitLLVM X(argc, argv);
+  initTool(argc, argv, "0.1", ReportMergeCategory, ToolHeading);
+
+  ReportMerger Merger;
+  for (const std::string &Path : InputPaths) {
+    Merger.add(readLog(Path), Path);
+  }
+  writeLog(Merger.takeMerged(), OutputPath);
+
+  return 0;
+}
diff --git a/llvm/utils/gn/secondary/clang/test/BUILD.gn b/llvm/utils/gn/secondary/clang/test/BUILD.gn
index 370c93f043f628..80b7c88dcf2d19 100644
--- a/llvm/utils/gn/secondary/clang/test/BUILD.gn
+++ b/llvm/utils/gn/secondary/clang/test/BUILD.gn
@@ -168,6 +168,7 @@ group("test") {
     "//clang/tools/clang-ssaf-format",
     "//clang/tools/clang-ssaf-linker",
     "//clang/tools/clang-ssaf-orchestrator-make",
+    "//clang/tools/clang-ssaf-report-merge",
     "//clang/tools/clang-ssaf-src-edit-merge",
     "//clang/tools/clang-sycl-linker",
     "//clang/tools/diagtool",
diff --git a/llvm/utils/gn/secondary/clang/tools/clang-ssaf-report-merge/BUILD.gn b/llvm/utils/gn/secondary/clang/tools/clang-ssaf-report-merge/BUILD.gn
new file mode 100644
index 00000000000000..2d2cf633b3556f
--- /dev/null
+++ b/llvm/utils/gn/secondary/clang/tools/clang-ssaf-report-merge/BUILD.gn
@@ -0,0 +1,8 @@
+executable("clang-ssaf-report-merge") {
+  configs += [ "//llvm/utils/gn/build:clang_code" ]
+  deps = [
+    "//clang/lib/ScalableStaticAnalysis/Tool",
+    "//llvm/lib/Support",
+  ]
+  sources = [ "ReportMerge.cpp" ]
+}



More information about the llvm-branch-commits mailing list