[llvm-branch-commits] [clang] [llvm] [clang][ssaf] Add clang-ssaf-driver (PR #232549)

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


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

Add a tool that runs a source transformation over a project as it builds
and presents the result. It knows which summaries and which
whole-program analysis each transformation needs, so a user names only
the transformation and the build command; cpp-bounded-buffers is the
first. It drives projects built with make through
clang-ssaf-orchestrator-make.

The driver writes the link unit's source edits as a patch, and prints
the transformation report the way a compiler prints diagnostics as well
as writing it as SARIF. A merge resolves only the conflicts among its
own inputs, so an edit that overlaps a reported conflict is left out of
the patch.

The tool is documented in
clang/docs/ScalableStaticAnalysis/user-docs/Driver.md. Unit tests cover
the internals, and lit tests run it over small make projects. Like the
orchestrator, it is neither built nor tested on Windows.

This is a generalization of a tool that we proposed earlier,
clang-reforge. We want to build a general-purpose analysis and
transformation driver decoupled from the specifics.

This relands a8797d63e97f (#231999), reverted with its stack in
782610a701b4 (#232274), without --apply, which needed
clang-apply-replacements from clang-tools-extra. The GN build files come
from 6abd4709cff4 (#232118).

Assisted-by: Claude



>From 601d80f46e52b81fff38cd7908fb9a9fe1b4ca58 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-driver

Add a tool that runs a source transformation over a project as it builds
and presents the result. It knows which summaries and which
whole-program analysis each transformation needs, so a user names only
the transformation and the build command; cpp-bounded-buffers is the
first. It drives projects built with make through
clang-ssaf-orchestrator-make.

The driver writes the link unit's source edits as a patch, and prints
the transformation report the way a compiler prints diagnostics as well
as writing it as SARIF. A merge resolves only the conflicts among its
own inputs, so an edit that overlaps a reported conflict is left out of
the patch.

The tool is documented in
clang/docs/ScalableStaticAnalysis/user-docs/Driver.md. Unit tests cover
the internals, and lit tests run it over small make projects. Like the
orchestrator, it is neither built nor tested on Windows.

This is a generalization of a tool that we proposed earlier,
clang-reforge. We want to build a general-purpose analysis and
transformation driver decoupled from the specifics.

This relands a8797d63e97f (#231999), reverted with its stack in
782610a701b4 (#232274), without --apply, which needed
clang-apply-replacements from clang-tools-extra. The GN build files come
from 6abd4709cff4 (#232118).

Assisted-by: Claude
---
 .../user-docs/Driver.md                       |  94 +++++++
 .../ssaf-driver/archive-conflict.test         |  56 ++++
 .../Scalable/ssaf-driver/bad-args.test        |  12 +
 .../Scalable/ssaf-driver/build-failure.test   |  17 ++
 .../Scalable/ssaf-driver/conflicts.test       |  58 ++++
 .../Analysis/Scalable/ssaf-driver/help.test   |  26 ++
 .../Scalable/ssaf-driver/lit.local.cfg        |  93 +++++++
 .../Analysis/Scalable/ssaf-driver/report.test |  48 ++++
 .../Scalable/ssaf-driver/transformation.test  |  72 +++++
 .../Scalable/ssaf-driver/unittests.test       |   8 +
 clang/test/CMakeLists.txt                     |   1 +
 clang/tools/CMakeLists.txt                    |   1 +
 clang/tools/clang-ssaf-driver/CMakeLists.txt  |  74 ++++++
 clang/tools/clang-ssaf-driver/README.md       |  34 +++
 .../clang-ssaf-driver/bin/clang-ssaf-driver   |  15 ++
 .../lib/clang_ssaf_driver/__init__.py         |  10 +
 .../lib/clang_ssaf_driver/driver.py           |  93 +++++++
 .../clang_ssaf_driver/logging_diagnostics.py  |  31 +++
 .../clang_ssaf_driver/make_orchestrator.py    |  64 +++++
 .../lib/clang_ssaf_driver/options.py          | 142 ++++++++++
 .../lib/clang_ssaf_driver/patch.py            | 129 +++++++++
 .../lib/clang_ssaf_driver/report.py           | 136 ++++++++++
 .../lib/clang_ssaf_driver/source_edits.py     | 150 +++++++++++
 .../lib/clang_ssaf_driver/transformations.py  |  18 ++
 .../lib/clang_ssaf_driver/version.py.in       |   5 +
 .../tools/clang-ssaf-driver/tests/__init__.py |   3 +
 .../clang-ssaf-driver/tests/test_driver.py    | 123 +++++++++
 .../tests/test_make_orchestrator.py           | 137 ++++++++++
 .../clang-ssaf-driver/tests/test_options.py   |  72 +++++
 .../clang-ssaf-driver/tests/test_patch.py     | 249 ++++++++++++++++++
 .../clang-ssaf-driver/tests/test_report.py    | 204 ++++++++++++++
 .../tests/test_source_edits.py                | 141 ++++++++++
 llvm/utils/gn/secondary/clang/test/BUILD.gn   |   1 +
 .../clang/tools/clang-ssaf-driver/BUILD.gn    |  40 +++
 34 files changed, 2357 insertions(+)
 create mode 100644 clang/docs/ScalableStaticAnalysis/user-docs/Driver.md
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/archive-conflict.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/bad-args.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/build-failure.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/conflicts.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/help.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/lit.local.cfg
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/report.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/transformation.test
 create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/unittests.test
 create mode 100644 clang/tools/clang-ssaf-driver/CMakeLists.txt
 create mode 100644 clang/tools/clang-ssaf-driver/README.md
 create mode 100755 clang/tools/clang-ssaf-driver/bin/clang-ssaf-driver
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/__init__.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/driver.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/logging_diagnostics.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/make_orchestrator.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/options.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/patch.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/report.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/source_edits.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/transformations.py
 create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/version.py.in
 create mode 100644 clang/tools/clang-ssaf-driver/tests/__init__.py
 create mode 100644 clang/tools/clang-ssaf-driver/tests/test_driver.py
 create mode 100644 clang/tools/clang-ssaf-driver/tests/test_make_orchestrator.py
 create mode 100644 clang/tools/clang-ssaf-driver/tests/test_options.py
 create mode 100644 clang/tools/clang-ssaf-driver/tests/test_patch.py
 create mode 100644 clang/tools/clang-ssaf-driver/tests/test_report.py
 create mode 100644 clang/tools/clang-ssaf-driver/tests/test_source_edits.py
 create mode 100644 llvm/utils/gn/secondary/clang/tools/clang-ssaf-driver/BUILD.gn

diff --git a/clang/docs/ScalableStaticAnalysis/user-docs/Driver.md b/clang/docs/ScalableStaticAnalysis/user-docs/Driver.md
new file mode 100644
index 00000000000000..5a02bf7818eca5
--- /dev/null
+++ b/clang/docs/ScalableStaticAnalysis/user-docs/Driver.md
@@ -0,0 +1,94 @@
+# Transforming a Project
+
+`clang-ssaf-driver` runs a source transformation over a whole project as it
+builds, and presents what it found: the source edits as a patch, and the
+transformation report as diagnostics and as SARIF. It drives projects built
+with make through `clang-ssaf-orchestrator-make`, whose requirements it
+shares; see {doc}`MakeProjects`.
+
+## Usage
+
+Run it from the project root, with its options before the build command:
+
+```console
+$ clang-ssaf-driver --transformation=cpp-bounded-buffers -- make -j8
+```
+
+The driver knows which summaries and which whole-program analysis each
+transformation needs:
+
+```{eval-rst}
+.. list-table::
+   :header-rows: 1
+
+   * - Transformation
+     - What it does
+   * - ``cpp-bounded-buffers``
+     - Rewrites the raw pointers and arrays that need bounds into
+       ``bounded_ptr`` and ``bounded_array``.
+```
+
+## Results
+
+A run writes two files into the output directory:
+
+- `<transformation>.patch`, the source edits as one unified diff whose paths
+  are relative to the project root, for `patch -p1` from there;
+- `<transformation>.sarif`, the transformation report: the findings of every
+  translation unit, and every conflict between source edits.
+
+Both are named on stdout, after the orchestrator's own results:
+
+```text
+patch: /work/proj/ssaf-output/cpp-bounded-buffers.patch
+report: /work/proj/ssaf-output/cpp-bounded-buffers.sarif
+```
+
+The report is also printed on stderr, one line per finding the way a compiler
+prints a diagnostic, and closes with a count for each rule:
+
+```text
+src/shared.h:1:8: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+src/shared.h:3:1: error: 2 overlapping replacement(s) at file:///work/proj/src/shared.h byte 20 were dropped; resolve manually. [clang-reforge-replacement-conflict]
+src/shared.h:5:1: note: candidate edit: "bounded_ptr<int> "
+src/shared.h:3:1: note: candidate edit: "bounded_ptr<long> "
+1 clang-reforge-replacement-conflict, 1 cpp-bounded-buffers-skipped reported
+```
+
+A finding is a place the transformation did not rewrite. A conflict is a place
+where translation units asked for different edits; none of them is made, and
+an edit that overlaps a conflict is left out of the patch too, with a warning.
+Both are left to be done by hand, and neither changes the exit status.
+
+## Options
+
+```{eval-rst}
+.. list-table::
+   :header-rows: 1
+
+   * - Option
+     - Purpose
+   * - ``--transformation=<name>``
+     - The transformation to run. Required.
+   * - ``-o``, ``--output-dir=<dir>``
+     - Where the results and the SSAF artifacts go. Defaults to
+       ``ssaf-output``, and is the orchestrator's output directory.
+   * - ``--ssaf-clang=<path>``, ``--ssaf-linker=<path>``,
+       ``--ssaf-analyzer=<path>``, ``--ssaf-src-edit-merge=<path>``,
+       ``--ssaf-report-merge=<path>``,
+       ``--ssaf-conflict-merge=<path>``,
+       ``--ssaf-extra-clang-arg=<arg>``, ``-k``, ``-v``
+     - Passed to the orchestrator; see {doc}`MakeProjects`.
+   * - ``--version``
+     - The clang version this was built from.
+```
+
+## What is not supported
+
+**Edits outside the project root.** A file the project does not contain is
+left out of the patch and is not applied to; the driver names it in a warning.
+
+**Compilations that run in another directory**, as in recursive make or
+`make -C`. A source edit names its file relative to where its compilation ran,
+and is merged where the link runs, so such an edit can be lost or land on the
+wrong file. Run the build from the project root.
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/archive-conflict.test b/clang/test/Analysis/Scalable/ssaf-driver/archive-conflict.test
new file mode 100644
index 00000000000000..4ecb37c0a20b9d
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/archive-conflict.test
@@ -0,0 +1,56 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+# RUN: mkdir -p %t/build
+
+# The two halves of the archive disagree on the header, so the archive's merge
+# drops both of their edits to it. main.cpp asks for one of them again, and
+# the link's own merge keeps it; the driver leaves it out, because it
+# overlaps a reported conflict.
+# RUN: cd %t
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN:     --ssaf-clang=%ssaf-driver-clang -o %t/out -- make \
+# RUN:     2> %t/stderr.txt > /dev/null
+# RUN: FileCheck %s --check-prefix=STDERR --input-file=%t/stderr.txt
+# STDERR: src/shared.h:3:1: error: 2 overlapping replacement(s) at {{.*}} were dropped; resolve manually. [clang-reforge-replacement-conflict]
+# STDERR: clang-ssaf-driver: warning: left out 1 edit(s) to src/shared.h that overlap a reported conflict
+
+# RUN: FileCheck %s --check-prefix=PATCH \
+# RUN:   --input-file=%t/out/cpp-bounded-buffers.patch
+# PATCH:     +++ b/src/a.cpp
+# PATCH:     +bounded_ptr<int> g;
+# PATCH-NOT: shared.h
+
+#--- Makefile
+CXX = clang++
+all: build/prg
+build/%.o: src/%.cpp src/shared.h
+	$(CXX) -c -o $@ $<
+build/libparts.a: build/a.o build/b.o
+	ar rcs $@ build/a.o build/b.o
+build/prg: build/main.o build/libparts.a
+	$(CXX) -o $@ build/main.o build/libparts.a
+
+#--- src/shared.h
+extern
+#if USE_LONG
+long
+#else
+int
+#endif
+*g;
+
+#--- src/a.cpp
+#include "shared.h"
+int *g;
+int a() { return g[5]; }
+
+#--- src/b.cpp
+#define USE_LONG 1
+#include "shared.h"
+long b() { return g[7]; }
+
+#--- src/main.cpp
+#include "shared.h"
+int a();
+long b();
+int main() { return a() + (int)b(); }
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/bad-args.test b/clang/test/Analysis/Scalable/ssaf-driver/bad-args.test
new file mode 100644
index 00000000000000..f17bfb8bf1e424
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/bad-args.test
@@ -0,0 +1,12 @@
+
+# RUN: not %clang-ssaf-driver -- make 2>&1 \
+# RUN:   | FileCheck %s --check-prefix=NO-TRANSFORMATION
+# NO-TRANSFORMATION: error: the following arguments are required: --transformation
+
+# RUN: not %clang-ssaf-driver --transformation=no-such -- make 2>&1 \
+# RUN:   | FileCheck %s --check-prefix=UNKNOWN
+# UNKNOWN: error: argument --transformation: invalid choice: 'no-such' {{.*}}cpp-bounded-buffers
+
+# RUN: not %clang-ssaf-driver --transformation=cpp-bounded-buffers 2>&1 \
+# RUN:   | FileCheck %s --check-prefix=NO-BUILD
+# NO-BUILD: clang-ssaf-driver: error: no build command given
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/build-failure.test b/clang/test/Analysis/Scalable/ssaf-driver/build-failure.test
new file mode 100644
index 00000000000000..732ea9b5bf26da
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/build-failure.test
@@ -0,0 +1,17 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+
+# A failing build fails the run with the orchestrator's status, and leaves
+# neither a patch nor a report.
+# RUN: cd %t
+# RUN: not %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN:     --ssaf-clang=%ssaf-driver-clang -o %t/out -- make 2>&1 \
+# RUN:   | FileCheck %s
+# CHECK: clang-ssaf-orchestrator-make: error: build failed (exit 2)
+# CHECK: clang-ssaf-driver: error: clang-ssaf-orchestrator-make failed (exit 2)
+# RUN: not ls %t/out/cpp-bounded-buffers.patch
+# RUN: not ls %t/out/cpp-bounded-buffers.sarif
+
+#--- Makefile
+all:
+	false
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/conflicts.test b/clang/test/Analysis/Scalable/ssaf-driver/conflicts.test
new file mode 100644
index 00000000000000..ea98af9a386dfe
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/conflicts.test
@@ -0,0 +1,58 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+# RUN: mkdir -p %t/build
+
+# The two translation units see different declarations of g in the header,
+# so their edits to it conflict and neither is applied. The conflict is
+# reported with both candidates, and the rest of the edits are still made.
+# RUN: cd %t
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN:     --ssaf-clang=%ssaf-driver-clang -o %t/out -- make \
+# RUN:     2> %t/stderr.txt > /dev/null
+
+# RUN: FileCheck %s --check-prefix=STDERR --input-file=%t/stderr.txt
+# STDERR:      src/shared.h:3:1: error: 2 overlapping replacement(s) at file://{{.*}}/src/shared.h byte 20 were dropped; resolve manually. [clang-reforge-replacement-conflict]
+# STDERR-DAG:  src/shared.h:3:1: note: candidate edit: "bounded_ptr<long> "
+# STDERR-DAG:  src/shared.h:5:1: note: candidate edit: "bounded_ptr<int> "
+# STDERR:      1 clang-reforge-replacement-conflict reported
+
+# RUN: FileCheck %s --check-prefix=SARIF \
+# RUN:   --input-file=%t/out/cpp-bounded-buffers.sarif
+# SARIF: "name": "clang-ssaf",
+# SARIF: "byteOffset": 20
+# SARIF: "ruleId": "clang-reforge-replacement-conflict"
+# SARIF: "name": "clang-ssaf-src-edit-merge",
+
+# RUN: FileCheck %s --check-prefix=PATCH \
+# RUN:   --input-file=%t/out/cpp-bounded-buffers.patch
+# PATCH:     +++ b/src/a.cpp
+# PATCH:     +bounded_ptr<int> g;
+# PATCH-NOT: shared.h
+
+#--- Makefile
+CXX = clang++
+all: build/prg
+build/%.o: src/%.cpp src/shared.h
+	$(CXX) -c -o $@ $<
+build/prg: build/a.o build/b.o
+	$(CXX) -o $@ build/a.o build/b.o
+
+#--- src/shared.h
+extern
+#if USE_LONG
+long
+#else
+int
+#endif
+*g;
+
+#--- src/a.cpp
+#include "shared.h"
+int *g;
+long b();
+int main() { return g[5] + (int)b(); }
+
+#--- src/b.cpp
+#define USE_LONG 1
+#include "shared.h"
+long b() { return g[7]; }
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/help.test b/clang/test/Analysis/Scalable/ssaf-driver/help.test
new file mode 100644
index 00000000000000..96857ce9e5fd22
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/help.test
@@ -0,0 +1,26 @@
+
+# RUN: %clang-ssaf-driver --help | FileCheck %s
+
+# CHECK: usage: clang-ssaf-driver [options] --transformation=<name> [--] <build command>
+# CHECK: --version
+# CHECK: --transformation <name>
+# CHECK: --output-dir
+# CHECK: --ssaf-clang
+# CHECK: --ssaf-linker
+# CHECK: --ssaf-analyzer
+# CHECK: --ssaf-src-edit-merge
+# CHECK: --ssaf-report-merge
+# CHECK: --ssaf-conflict-merge
+# CHECK: --ssaf-extra-clang-arg
+# CHECK: --keep-going
+# CHECK: --verbose
+
+# RUN: %clang-ssaf-driver --version | FileCheck %s --check-prefix=VERSION
+# VERSION: clang-ssaf-driver {{[0-9]+\.[0-9]+\.[0-9]+}}
+
+# The tool imports its own package, whatever else of that name PYTHONPATH holds.
+# RUN: rm -rf %t && mkdir -p %t/clang_ssaf_driver
+# RUN: echo 'raise SystemExit("a decoy was imported")' \
+# RUN:   > %t/clang_ssaf_driver/__init__.py
+# RUN: env PYTHONPATH=%t %clang-ssaf-driver --version \
+# RUN:   | FileCheck %s --check-prefix=VERSION
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/lit.local.cfg b/clang/test/Analysis/Scalable/ssaf-driver/lit.local.cfg
new file mode 100644
index 00000000000000..10c9b7e10b3802
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/lit.local.cfg
@@ -0,0 +1,93 @@
+# -*- Python -*-
+
+import lit.util
+import os
+import platform
+import subprocess
+import tempfile
+
+if platform.system() == "Windows":
+    config.unsupported = True
+
+
+def _run(command, stdin=None):
+    try:
+        return subprocess.run(
+            command,
+            input=stdin,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.DEVNULL,
+            env=config.environment,
+        )
+    except OSError:
+        return None
+
+
+def _succeeds(command, stdin=None):
+    done = _run(command, stdin)
+    return done is not None and done.returncode == 0
+
+
+# The tests build projects with make, so they need GNU make, and a clang and
+# an ar that compile, archive and link C++ programs for the host.
+def _can_build_projects():
+    path = config.environment["PATH"]
+    make = lit.util.which("make", path)
+    version = _run([make, "--version"]) if make else None
+    if not version or version.returncode or not version.stdout.startswith(b"GNU Make"):
+        return False
+    ar = lit.util.which("ar", path)
+    with tempfile.TemporaryDirectory() as tmp:
+        obj = os.path.join(tmp, "a.o")
+        cxx = [config.clang, "--driver-mode=g++"]
+        main = b"int main() { return 0; }\n"
+        return (
+            ar is not None
+            and _succeeds(cxx + ["-x", "c++", "-", "-c", "-o", obj], main)
+            and _succeeds([ar, "rcs", os.path.join(tmp, "liba.a"), obj])
+            and _succeeds(cxx + [obj, "-o", os.path.join(tmp, "a.out")])
+        )
+
+
+if "native" in config.available_features and _can_build_projects():
+    config.available_features.add("ssaf-driver-make-build")
+
+# GNU and BSD patch answer --version and take the options the tests use.
+if _succeeds(["patch", "--version"]):
+    config.available_features.add("patch")
+
+config.substitutions.append(
+    (
+        "%clang-ssaf-driver",
+        '"%s" "%s"'
+        % (
+            config.python_executable,
+            os.path.join(config.llvm_tools_dir, "clang-ssaf-driver"),
+        ),
+    )
+)
+
+config.substitutions.append(
+    ("%ssaf-driver-python", '"%s"' % config.python_executable)
+)
+
+config.substitutions.append(
+    (
+        "%ssaf-driver-libs",
+        os.path.join(os.path.dirname(config.llvm_tools_dir), "lib"),
+    )
+)
+
+config.substitutions.append(
+    (
+        "%ssaf-driver-src",
+        os.path.join(config.clang_src_dir, "tools", "clang-ssaf-driver"),
+    )
+)
+
+config.substitutions.append(
+    (
+        "%ssaf-driver-clang",
+        config.clang if config.have_llvm_driver else os.path.realpath(config.clang),
+    )
+)
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/report.test b/clang/test/Analysis/Scalable/ssaf-driver/report.test
new file mode 100644
index 00000000000000..979eebda5e5a40
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/report.test
@@ -0,0 +1,48 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+# RUN: mkdir -p %t/build
+
+# Both translation units report the declaration group in the header; the
+# report has each of its declarators once, after main.cpp's own group.
+# RUN: cd %t
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN:     --ssaf-clang=%ssaf-driver-clang -o %t/out -- make \
+# RUN:     2> %t/stderr.txt | FileCheck %s --check-prefix=STDOUT
+# STDOUT: report: {{[^ ]*}}/out/cpp-bounded-buffers.sarif
+
+# RUN: FileCheck %s --check-prefix=STDERR --input-file=%t/stderr.txt
+# STDERR:      src/main.cpp:2:1: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+# STDERR-NEXT: src/main.cpp:2:1: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+# STDERR-NEXT: src/shared.h:1:8: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+# STDERR-NEXT: src/shared.h:1:8: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+# STDERR-NEXT: 4 cpp-bounded-buffers-skipped reported
+
+# The report holds the same findings, and the patch changes nothing.
+# RUN: FileCheck %s --check-prefix=SARIF \
+# RUN:   --input-file=%t/out/cpp-bounded-buffers.sarif
+# RUN: grep 'multi-declarator group' %t/out/cpp-bounded-buffers.sarif | count 4
+# RUN: count 0 < %t/out/cpp-bounded-buffers.patch
+# SARIF: "runs": [
+# SARIF: "name": "clang-ssaf",
+# SARIF: "name": "clang-ssaf-src-edit-merge",
+
+#--- Makefile
+CXX = clang++
+all: build/prg
+build/%.o: src/%.cpp src/shared.h
+	$(CXX) -c -o $@ $<
+build/prg: build/main.o build/use.o
+	$(CXX) -o $@ build/main.o build/use.o
+
+#--- src/shared.h
+extern int *p, *q;
+
+#--- src/use.cpp
+#include "shared.h"
+int use() { return p[1] + q[1]; }
+
+#--- src/main.cpp
+#include "shared.h"
+int *p, *q;
+int use();
+int main() { return use(); }
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/transformation.test b/clang/test/Analysis/Scalable/ssaf-driver/transformation.test
new file mode 100644
index 00000000000000..a51008e2335f16
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/transformation.test
@@ -0,0 +1,72 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+# RUN: mkdir -p %t/build
+
+# RUN: cd %t
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN:     --ssaf-clang=%ssaf-driver-clang -o %t/out -- make \
+# RUN:   | FileCheck %s --check-prefix=STDOUT
+# STDOUT: source-edits: {{[^ ]*}}/out/ssaf-artifacts{{[^ ]*}}/build/prg.edits.yaml
+# STDOUT: patch: {{[^ ]*}}/out/cpp-bounded-buffers.patch
+# STDOUT: report: {{[^ ]*}}/out/cpp-bounded-buffers.sarif
+
+# RUN: diff %t/expected.patch %t/out/cpp-bounded-buffers.patch
+
+# The sources are left as they were.
+# RUN: FileCheck %s --check-prefix=UNCHANGED --input-file=%t/src/parse.cpp
+# UNCHANGED: int total(int *data, int n) {
+
+# A second run into the same output directory starts afresh.
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN:     --ssaf-clang=%ssaf-driver-clang -o %t/out -- make > %t/second.txt
+# RUN: diff %t/expected.patch %t/out/cpp-bounded-buffers.patch
+
+# The patch applies to the sources.
+# RUN: %if patch %{ rm -rf %t/copy && mkdir -p %t/copy && cp -R %t/src %t/copy %}
+# RUN: %if patch %{ patch -s -p1 -d %t/copy < %t/out/cpp-bounded-buffers.patch %}
+# RUN: %if patch %{ FileCheck %s --check-prefix=PATCHED --input-file=%t/copy/src/parse.cpp %}
+# PATCHED: int total(bounded_ptr<int> data, int n) {
+
+#--- Makefile
+CXX = clang++
+all: build/prg
+build/%.o: src/%.cpp
+	$(CXX) -c -o $@ $<
+build/prg: build/main.o build/parse.o
+	$(CXX) -o $@ build/main.o build/parse.o
+
+#--- src/parse.cpp
+int total(int *data, int n) {
+  int t = 0;
+  for (int i = 0; i < n; ++i)
+    t += data[i];
+  return t;
+}
+
+#--- src/main.cpp
+int total(int *data, int n);
+int main() {
+  int values[4] = {1, 2, 3, 4};
+  return total(values, 4);
+}
+
+#--- expected.patch
+--- a/src/main.cpp
++++ b/src/main.cpp
+@@ -1,6 +1,6 @@
+-int total(int *data, int n);
++int total(bounded_ptr<int> data, int n);
+ int main() {
+-  int values[4] = {1, 2, 3, 4};
++  bounded_array<int, 4> values = {1, 2, 3, 4};
+   return total(values, 4);
+ }
+ 
+--- a/src/parse.cpp
++++ b/src/parse.cpp
+@@ -1,4 +1,4 @@
+-int total(int *data, int n) {
++int total(bounded_ptr<int> data, int n) {
+   int t = 0;
+   for (int i = 0; i < n; ++i)
+     t += data[i];
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/unittests.test b/clang/test/Analysis/Scalable/ssaf-driver/unittests.test
new file mode 100644
index 00000000000000..60ab238f41cb17
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/unittests.test
@@ -0,0 +1,8 @@
+
+# RUN: env PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=%ssaf-driver-libs \
+# RUN:     %ssaf-driver-python -m unittest discover \
+# RUN:   -s %ssaf-driver-src/tests -t %ssaf-driver-src \
+# RUN:   > %t.log 2>&1
+# RUN: FileCheck %s --input-file=%t.log --implicit-check-not=FAILED
+
+# CHECK: {{^OK}}
diff --git a/clang/test/CMakeLists.txt b/clang/test/CMakeLists.txt
index e96711ffbe2cdf..2ef2d1024fdefc 100644
--- a/clang/test/CMakeLists.txt
+++ b/clang/test/CMakeLists.txt
@@ -117,6 +117,7 @@ list(APPEND CLANG_TEST_DEPS
 
 if(NOT WIN32)
   list(APPEND CLANG_TEST_DEPS
+    clang-ssaf-driver
     clang-ssaf-orchestrator-make
     )
 endif()
diff --git a/clang/tools/CMakeLists.txt b/clang/tools/CMakeLists.txt
index c7369b18c06394..2b0d706185902c 100644
--- a/clang/tools/CMakeLists.txt
+++ b/clang/tools/CMakeLists.txt
@@ -20,6 +20,7 @@ add_clang_subdirectory(clang-ssaf-conflict-merge)
 add_clang_subdirectory(clang-ssaf-format)
 add_clang_subdirectory(clang-ssaf-linker)
 if(NOT WIN32)
+  add_clang_subdirectory(clang-ssaf-driver)
   add_clang_subdirectory(clang-ssaf-orchestrator-make)
 endif()
 add_clang_subdirectory(clang-ssaf-report-merge)
diff --git a/clang/tools/clang-ssaf-driver/CMakeLists.txt b/clang/tools/clang-ssaf-driver/CMakeLists.txt
new file mode 100644
index 00000000000000..d805805f171b45
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/CMakeLists.txt
@@ -0,0 +1,74 @@
+set(SsafDriverLib
+    "__init__.py"
+    "driver.py"
+    "logging_diagnostics.py"
+    "make_orchestrator.py"
+    "options.py"
+    "patch.py"
+    "report.py"
+    "source_edits.py"
+    "transformations.py")
+
+set(SsafDriverPackageDir "lib/clang_ssaf_driver")
+
+# Copy one file into the build tree and install it, recording the copy so the
+# tool's target can depend on it. kind is FILES or PROGRAMS.
+function(add_ssaf_driver_file source build_dir destination kind)
+  get_filename_component(name "${source}" NAME)
+  set(copied "${LLVM_BINARY_DIR}/${build_dir}/${name}")
+  add_custom_command(OUTPUT "${copied}"
+                     COMMAND ${CMAKE_COMMAND} -E make_directory
+                       "${LLVM_BINARY_DIR}/${build_dir}"
+                     COMMAND ${CMAKE_COMMAND} -E copy
+                       "${CMAKE_CURRENT_SOURCE_DIR}/${source}" "${copied}"
+                     DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/${source}")
+  set(SSAF_DRIVER_DEPENDS ${SSAF_DRIVER_DEPENDS} "${copied}" PARENT_SCOPE)
+  install(${kind} "${source}"
+          DESTINATION "${destination}"
+          COMPONENT clang-ssaf-driver)
+endfunction()
+
+add_ssaf_driver_file("bin/clang-ssaf-driver"
+                     "bin" "${CMAKE_INSTALL_BINDIR}" PROGRAMS)
+
+foreach(lib ${SsafDriverLib})
+  add_ssaf_driver_file("${SsafDriverPackageDir}/${lib}"
+                       "${SsafDriverPackageDir}" "${SsafDriverPackageDir}" FILES)
+endforeach()
+
+# Substituted at configure time into this directory's own build area, then
+# copied like every other file, so that deleting the package from the build
+# tree leaves a rule to put it back.
+configure_file(
+  "${CMAKE_CURRENT_SOURCE_DIR}/${SsafDriverPackageDir}/version.py.in"
+  "${CMAKE_CURRENT_BINARY_DIR}/version.py")
+set(version_py "${LLVM_BINARY_DIR}/${SsafDriverPackageDir}/version.py")
+add_custom_command(OUTPUT "${version_py}"
+                   COMMAND ${CMAKE_COMMAND} -E make_directory
+                     "${LLVM_BINARY_DIR}/${SsafDriverPackageDir}"
+                   COMMAND ${CMAKE_COMMAND} -E copy
+                     "${CMAKE_CURRENT_BINARY_DIR}/version.py" "${version_py}"
+                   DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/version.py")
+list(APPEND SSAF_DRIVER_DEPENDS "${version_py}")
+install(FILES "${CMAKE_CURRENT_BINARY_DIR}/version.py"
+        DESTINATION "${SsafDriverPackageDir}"
+        COMPONENT clang-ssaf-driver)
+
+add_custom_target(clang-ssaf-driver ALL DEPENDS ${SSAF_DRIVER_DEPENDS})
+set_target_properties(clang-ssaf-driver PROPERTIES FOLDER "Clang/Tools")
+if(NOT LLVM_ENABLE_IDE)
+  add_llvm_install_targets("install-clang-ssaf-driver"
+                           DEPENDS clang-ssaf-driver
+                           COMPONENT clang-ssaf-driver)
+endif()
+
+# The driver runs the orchestrator installed beside it.
+if(CLANG_TOOL_CLANG_SSAF_ORCHESTRATOR_MAKE_BUILD)
+  add_dependencies(clang-ssaf-driver clang-ssaf-orchestrator-make)
+  if(NOT LLVM_ENABLE_IDE)
+    add_dependencies(install-clang-ssaf-driver
+                     install-clang-ssaf-orchestrator-make)
+    add_dependencies(install-clang-ssaf-driver-stripped
+                     install-clang-ssaf-orchestrator-make-stripped)
+  endif()
+endif()
diff --git a/clang/tools/clang-ssaf-driver/README.md b/clang/tools/clang-ssaf-driver/README.md
new file mode 100644
index 00000000000000..7071a2a39e0c5d
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/README.md
@@ -0,0 +1,34 @@
+# clang-ssaf-driver
+
+Runs a Scalable Static Analysis Framework source transformation over a project
+as it builds, and presents the result: the source edits as a patch, and the
+transformation report as diagnostics and as SARIF. Projects built with make
+are driven through `clang-ssaf-orchestrator-make`, installed beside it.
+
+- Using it: [Transforming a Project](
+  ../../docs/ScalableStaticAnalysis/user-docs/Driver.md)
+
+## Layout
+
+| | |
+|---|---|
+| `bin/` | the entry script, installed to `bin/` |
+| `lib/clang_ssaf_driver/` | the package, installed to `lib/` |
+| `tests/` | the Python unit tests |
+
+## Running the tests
+
+```
+PYTHONPATH=lib python3 -m unittest discover  # from this directory
+llvm-lit clang/test/Analysis/Scalable/ssaf-driver
+```
+
+The lit suite runs the unit tests too, against the copy in the build tree, so
+`ninja check-clang` covers both. The tool is a declared dependency of
+`check-clang`.
+
+## Packaging
+
+CMake copies the Python files into the build tree and installs them under the
+`clang-ssaf-driver` component, and installing it installs the orchestrator's
+component too.
diff --git a/clang/tools/clang-ssaf-driver/bin/clang-ssaf-driver b/clang/tools/clang-ssaf-driver/bin/clang-ssaf-driver
new file mode 100755
index 00000000000000..b21f4000e684d4
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/bin/clang-ssaf-driver
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+# 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 os.path
+import sys
+
+this_dir = os.path.dirname(os.path.realpath(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(this_dir), "lib"))
+
+from clang_ssaf_driver import driver
+
+if __name__ == "__main__":
+    sys.exit(driver.main(sys.argv[1:]))
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/__init__.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/__init__.py
new file mode 100644
index 00000000000000..5b5a2956e2697d
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/__init__.py
@@ -0,0 +1,10 @@
+# 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
+
+PROGRAM = "clang-ssaf-driver"
+
+try:
+    from .version import VERSION
+except ImportError:
+    VERSION = "unknown (not built)"
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/driver.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/driver.py
new file mode 100644
index 00000000000000..8cffb6673a870d
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/driver.py
@@ -0,0 +1,93 @@
+# 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 os
+
+from . import (
+    logging_diagnostics,
+    make_orchestrator,
+    options,
+    patch,
+    report,
+    source_edits,
+)
+from .transformations import TRANSFORMATIONS
+
+
+def run(command_line_opts):
+    orchestrator = make_orchestrator.find_orchestrator()
+    if not os.path.isfile(orchestrator):
+        return options.Problem(
+            options.ENVIRONMENT_ERROR,
+            "cannot find %s: %s" % (make_orchestrator.ORCHESTRATOR, orchestrator),
+        )
+    project_root = os.path.realpath(os.getcwd())
+    output_dir = os.path.realpath(command_line_opts.output_dir)
+    status, results = make_orchestrator.run_orchestrator(
+        make_orchestrator.orchestrator_command(
+            orchestrator,
+            TRANSFORMATIONS[command_line_opts.transformation],
+            command_line_opts,
+        ),
+        command_line_opts.verbose,
+    )
+    if status != 0:
+        return options.Problem(
+            status, "%s failed (exit %d)" % (make_orchestrator.ORCHESTRATOR, status)
+        )
+    if "source-edits" not in results:
+        return options.Problem(
+            options.ENVIRONMENT_ERROR,
+            "%s reported no source edits" % make_orchestrator.ORCHESTRATOR,
+        )
+
+    replacements, problem = source_edits.read_source_edits(results["source-edits"])
+    if problem is not None:
+        return problem
+    log, problem = report.load(
+        [results.get("transformation-report"), results.get("conflict-report")]
+    )
+    if problem is not None:
+        return problem
+    collected, problem = patch.collect(
+        replacements, project_root, report.conflict_ranges(log)
+    )
+    if problem is not None:
+        return problem
+
+    for line in report.diagnostics(log, project_root):
+        logging_diagnostics.report_finding(line)
+    for path in collected.outside:
+        logging_diagnostics.report_warning(
+            "edits to a file outside the project were left out: %s" % path
+        )
+    for relative_path, count in collected.blocked:
+        logging_diagnostics.report_warning(
+            "left out %d edit(s) to %s that overlap a reported conflict"
+            % (count, relative_path)
+        )
+
+    patch_path = os.path.join(output_dir, command_line_opts.transformation + ".patch")
+    report_path = os.path.join(output_dir, command_line_opts.transformation + ".sarif")
+    try:
+        with open(patch_path, "wb") as handle:
+            handle.write(patch.render(collected.files))
+        report.write(report_path, log)
+    except OSError as error:
+        return options.Problem(
+            options.ENVIRONMENT_ERROR, "cannot write the results: %s" % error
+        )
+    logging_diagnostics.report_info("patch", patch_path)
+    logging_diagnostics.report_info("report", report_path)
+    return None
+
+
+def main(argv=None):
+    command_line_opts, problem = options.resolve(argv)
+    if problem is None:
+        problem = run(command_line_opts)
+    if problem is not None:
+        logging_diagnostics.report_error(problem.message, problem.details)
+        return problem.exit_status
+    return 0
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/logging_diagnostics.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/logging_diagnostics.py
new file mode 100644
index 00000000000000..c5365d4e35126f
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/logging_diagnostics.py
@@ -0,0 +1,31 @@
+# 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 sys
+
+from . import PROGRAM
+
+
+def report_note(verbose, message):
+    if verbose:
+        sys.stderr.write("%s: note: %s\n" % (PROGRAM, message))
+
+
+def report_warning(message):
+    sys.stderr.write("%s: warning: %s\n" % (PROGRAM, message))
+
+
+def report_error(message, details=()):
+    sys.stderr.write("%s: error: %s\n" % (PROGRAM, message))
+    for detail in details:
+        sys.stderr.write("  %s\n" % detail)
+
+
+def report_info(key, value):
+    sys.stdout.write("%s: %s\n" % (key, value))
+    sys.stdout.flush()
+
+
+def report_finding(text):
+    sys.stderr.write(text + "\n")
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/make_orchestrator.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/make_orchestrator.py
new file mode 100644
index 00000000000000..0f5190280e8e50
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/make_orchestrator.py
@@ -0,0 +1,64 @@
+# 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 os
+import subprocess
+import sys
+
+from . import logging_diagnostics
+
+ORCHESTRATOR = "clang-ssaf-orchestrator-make"
+
+RESULT_KEYS = frozenset(["source-edits", "transformation-report", "conflict-report"])
+
+
+def find_orchestrator():
+    package_dir = os.path.dirname(os.path.realpath(__file__))
+    prefix = os.path.dirname(os.path.dirname(package_dir))
+    return os.path.join(prefix, "bin", ORCHESTRATOR)
+
+
+def orchestrator_command(orchestrator, transformation, command_line_opts):
+    return (
+        [
+            sys.executable,
+            orchestrator,
+            "--extract-summaries=" + transformation.extract_summaries,
+            "--analysis=" + transformation.analysis,
+            "--transformation=" + transformation.transformation,
+            "--output-dir=" + command_line_opts.output_dir,
+        ]
+        + command_line_opts.orchestrator_args
+        + ["--"]
+        + command_line_opts.build_command
+    )
+
+
+def exit_status(return_code):
+    return 128 - return_code if return_code < 0 else return_code
+
+
+def read_result(line):
+    key, separator, value = line.rstrip(b"\n").partition(b": ")
+    key = key.decode("ascii", "replace")
+    if not separator or key not in RESULT_KEYS:
+        return None
+    return key, os.fsdecode(value)
+
+
+def run_orchestrator(command, verbose, relay=None):
+    relay = relay or sys.stdout.buffer
+    logging_diagnostics.report_note(verbose, "running: %s" % " ".join(command))
+    results = {}
+    with subprocess.Popen(command, stdout=subprocess.PIPE) as process:
+        try:
+            for line in process.stdout:
+                relay.write(line)
+                relay.flush()
+                result = read_result(line)
+                if result:
+                    results[result[0]] = result[1]
+        except KeyboardInterrupt:
+            pass
+    return exit_status(process.returncode), results
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/options.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/options.py
new file mode 100644
index 00000000000000..82f5f8ff436b7e
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/options.py
@@ -0,0 +1,142 @@
+# 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 collections
+
+from . import PROGRAM, VERSION
+from .transformations import TRANSFORMATIONS
+
+Problem = collections.namedtuple(
+    "Problem", "exit_status message details", defaults=((),)
+)
+
+USAGE_ERROR = 2
+ENVIRONMENT_ERROR = 1
+
+FORWARDED_TOOLS = (
+    (
+        "--ssaf-clang",
+        "SSAF-capable clang used for the SSAF compilations (default: clang on PATH).",
+    ),
+    ("--ssaf-linker", "Path to clang-ssaf-linker (default: found on PATH)."),
+    ("--ssaf-analyzer", "Path to clang-ssaf-analyzer (default: found on PATH)."),
+    (
+        "--ssaf-src-edit-merge",
+        "Path to clang-ssaf-src-edit-merge (default: found on PATH).",
+    ),
+    (
+        "--ssaf-report-merge",
+        "Path to clang-ssaf-report-merge (default: found on PATH).",
+    ),
+    (
+        "--ssaf-conflict-merge",
+        "Path to clang-ssaf-conflict-merge (default: found on PATH).",
+    ),
+)
+
+
+def build_parser():
+    parser = argparse.ArgumentParser(
+        prog=PROGRAM,
+        usage="%(prog)s [options] --transformation=<name> [--] <build command>",
+        description="Run an SSAF transformation over a project as it builds. "
+        "The source edits are written as a patch, and the transformation report "
+        "is printed and written as SARIF.",
+    )
+    parser.add_argument("--version", action="version", version="%(prog)s " + VERSION)
+    parser.add_argument(
+        "--transformation",
+        metavar="<name>",
+        required=True,
+        choices=sorted(TRANSFORMATIONS),
+        help="The transformation to run, one of: %s. Required."
+        % ", ".join(sorted(TRANSFORMATIONS)),
+    )
+    parser.add_argument(
+        "-o",
+        "--output-dir",
+        metavar="<dir>",
+        default="ssaf-output",
+        help="Directory for the results and the SSAF artifacts "
+        "(default: %(default)s).",
+    )
+    for option, purpose in FORWARDED_TOOLS:
+        parser.add_argument(option, metavar="<path>", default=None, help=purpose)
+    parser.add_argument(
+        "--ssaf-extra-clang-arg",
+        metavar="<arg>",
+        action="append",
+        default=[],
+        dest="ssaf_extra_clang_args",
+        help="Additional argument for every SSAF compilation. Repeatable.",
+    )
+    parser.add_argument(
+        "-k",
+        "--keep-going",
+        action="store_true",
+        help="Carry on even if some SSAF steps failed.",
+    )
+    parser.add_argument(
+        "-v",
+        "--verbose",
+        action="store_true",
+        help="Print every command that runs.",
+    )
+    parser.add_argument(
+        "build",
+        nargs=argparse.REMAINDER,
+        metavar="<build command>",
+        help="The build command. A '--' separator before it is optional.",
+    )
+    return parser
+
+
+class CommandLineOpts:
+    def __init__(
+        self,
+        build_command=(),
+        transformation=None,
+        output_dir=None,
+        orchestrator_args=(),
+        verbose=False,
+    ):
+        self.build_command = list(build_command)
+        self.transformation = transformation
+        self.output_dir = output_dir
+        self.orchestrator_args = list(orchestrator_args)
+        self.verbose = verbose
+
+
+def resolve(argv):
+    args = build_parser().parse_args(argv)
+
+    build_command = list(args.build)
+    if build_command and build_command[0] == "--":
+        build_command = build_command[1:]
+    if not build_command:
+        return None, Problem(USAGE_ERROR, "no build command given")
+
+    orchestrator_args = []
+    for option, _ in FORWARDED_TOOLS:
+        value = getattr(args, option[2:].replace("-", "_"))
+        if value is not None:
+            orchestrator_args.append("%s=%s" % (option, value))
+    for arg in args.ssaf_extra_clang_args:
+        orchestrator_args.append("--ssaf-extra-clang-arg=" + arg)
+    if args.keep_going:
+        orchestrator_args.append("--keep-going")
+    if args.verbose:
+        orchestrator_args.append("--verbose")
+
+    return (
+        CommandLineOpts(
+            build_command=build_command,
+            transformation=args.transformation,
+            output_dir=args.output_dir,
+            orchestrator_args=orchestrator_args,
+            verbose=args.verbose,
+        ),
+        None,
+    )
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/patch.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/patch.py
new file mode 100644
index 00000000000000..2ee1be8fe26395
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/patch.py
@@ -0,0 +1,129 @@
+# 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 collections
+import difflib
+import os
+
+from .options import ENVIRONMENT_ERROR, Problem
+
+FileEdits = collections.namedtuple(
+    "FileEdits", "relative_path path edits original edited"
+)
+
+Collected = collections.namedtuple("Collected", "files outside blocked")
+
+NO_NEWLINE = b"\\ No newline at end of file\n"
+
+
+def apply_edits(original, edits):
+    pieces = []
+    position = 0
+    previous = None
+    for offset, length, text in edits:
+        if offset + length > len(original):
+            return None, "an edit at byte %d runs past the end of the file" % offset
+        if offset < position or (length == 0 and previous == (offset, 0)):
+            return None, "edits overlap at byte %d" % offset
+        pieces.append(original[position:offset])
+        pieces.append(text.encode("utf-8"))
+        position = offset + length
+        previous = (offset, length)
+    pieces.append(original[position:])
+    return b"".join(pieces), None
+
+
+def _identity(path):
+    try:
+        status = os.stat(path)
+    except OSError:
+        return path
+    return (status.st_dev, status.st_ino)
+
+
+def _overlaps(edit, start, end):
+    offset, length, _ = edit
+    if length == 0:
+        return start < offset < end
+    return offset < end and start < offset + length
+
+
+def collect(replacements, project_root, conflicts):
+    grouped = {}
+    paths = {}
+    for each in replacements:
+        path = os.path.realpath(os.path.join(project_root, each.file_path))
+        identity = _identity(path)
+        paths[identity] = min(paths.get(identity, path), path)
+        grouped.setdefault(identity, set()).add((each.offset, each.length, each.text))
+    conflicting = {}
+    for path, ranges in conflicts.items():
+        conflicting.setdefault(_identity(os.path.realpath(path)), []).extend(ranges)
+
+    files = []
+    outside = []
+    blocked = []
+    problems = []
+    for identity in sorted(grouped, key=lambda each: paths[each]):
+        path = paths[identity]
+        relative_path = os.path.relpath(path, project_root)
+        if relative_path == os.pardir or relative_path.startswith(os.pardir + os.sep):
+            outside.append(path)
+            continue
+        ranges = conflicting.get(identity, ())
+        edits = sorted(
+            (
+                edit
+                for edit in grouped[identity]
+                if not any(_overlaps(edit, start, end) for start, end in ranges)
+            ),
+            key=lambda edit: (edit[0], edit[1]),
+        )
+        if len(edits) < len(grouped[identity]):
+            blocked.append((relative_path, len(grouped[identity]) - len(edits)))
+        if not edits:
+            continue
+        try:
+            with open(path, "rb") as handle:
+                original = handle.read()
+        except OSError as error:
+            problems.append("%s: %s" % (relative_path, error.strerror))
+            continue
+        edited, problem = apply_edits(original, edits)
+        if problem:
+            problems.append("%s: %s" % (relative_path, problem))
+            continue
+        files.append(FileEdits(relative_path, path, edits, original, edited))
+
+    if problems:
+        return None, Problem(
+            ENVIRONMENT_ERROR, "the source edits cannot be applied:", problems
+        )
+    return Collected(files, outside, blocked), None
+
+
+def _lines(content):
+    lines = content.split(b"\n")
+    last = lines.pop()
+    lines = [line + b"\n" for line in lines]
+    if last:
+        lines.append(last)
+    return lines
+
+
+def render(files):
+    pieces = []
+    for each in files:
+        name = os.fsencode(each.relative_path)
+        for line in difflib.diff_bytes(
+            difflib.unified_diff,
+            _lines(each.original),
+            _lines(each.edited),
+            b"a/" + name,
+            b"b/" + name,
+        ):
+            pieces.append(line)
+            if not line.endswith(b"\n"):
+                pieces.append(b"\n" + NO_NEWLINE)
+    return b"".join(pieces)
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/report.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/report.py
new file mode 100644
index 00000000000000..975a9362bb8923
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/report.py
@@ -0,0 +1,136 @@
+# 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 collections
+import json
+import os
+import urllib.parse
+
+from . import PROGRAM
+from .options import ENVIRONMENT_ERROR, Problem
+
+SCHEMA = (
+    "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/"
+    "sarif-schema-2.1.0.json"
+)
+
+_LEVELS = {"error": "error", "warning": "warning", "note": "note", "none": "note"}
+
+_FILE_SCHEME = "file://"
+
+
+def load(paths):
+    runs = []
+    for path in paths:
+        if not path:
+            continue
+        try:
+            with open(path) as handle:
+                document = json.load(handle)
+        except (OSError, ValueError) as error:
+            return None, Problem(
+                ENVIRONMENT_ERROR, "cannot read the report %s: %s" % (path, error)
+            )
+        if not isinstance(document, dict) or not isinstance(document.get("runs"), list):
+            return None, Problem(ENVIRONMENT_ERROR, "not a SARIF log: %s" % path)
+        runs.extend(document["runs"])
+    return {"$schema": SCHEMA, "version": "2.1.0", "runs": runs}, None
+
+
+def write(path, log):
+    with open(path, "w") as handle:
+        json.dump(log, handle, indent=2, sort_keys=True)
+        handle.write("\n")
+
+
+class _Sources:
+    def __init__(self):
+        self.contents = {}
+
+    def position(self, path, offset):
+        if path not in self.contents:
+            try:
+                with open(path, "rb") as handle:
+                    self.contents[path] = handle.read()
+            except OSError:
+                self.contents[path] = None
+        content = self.contents[path]
+        if content is None or offset > len(content):
+            return None
+        line_start = content.rfind(b"\n", 0, offset) + 1
+        return content.count(b"\n", 0, offset) + 1, offset - line_start + 1
+
+
+def _path(uri):
+    if not uri.startswith(_FILE_SCHEME):
+        return uri
+    return urllib.parse.unquote(uri[len(_FILE_SCHEME) :])
+
+
+def _where(location, project_root, sources):
+    physical = location.get("physicalLocation") or {}
+    uri = (physical.get("artifactLocation") or {}).get("uri")
+    if not uri:
+        return PROGRAM
+    path = _path(uri)
+    shown = os.path.relpath(path, project_root)
+    if shown == os.pardir or shown.startswith(os.pardir + os.sep):
+        shown = path
+    region = physical.get("region") or {}
+    if "startLine" in region:
+        return "%s:%d:%d" % (shown, region["startLine"], region.get("startColumn", 1))
+    if "byteOffset" in region:
+        position = sources.position(path, region["byteOffset"])
+        if position:
+            return "%s:%d:%d" % ((shown,) + position)
+    return shown
+
+
+def _text(item):
+    return (item.get("message") or {}).get("text", "")
+
+
+def diagnostics(log, project_root):
+    sources = _Sources()
+    lines = []
+    counts = collections.Counter()
+    for run in log["runs"]:
+        for result in run.get("results") or []:
+            rule_id = result.get("ruleId", "")
+            counts[rule_id] += 1
+            locations = result.get("locations") or [{}]
+            lines.append(
+                "%s: %s: %s [%s]"
+                % (
+                    _where(locations[0], project_root, sources),
+                    _LEVELS.get(result.get("level"), "warning"),
+                    _text(result),
+                    rule_id,
+                )
+            )
+            for related in result.get("relatedLocations") or []:
+                lines.append(
+                    "%s: note: %s"
+                    % (_where(related, project_root, sources), _text(related))
+                )
+    if counts:
+        summary = ["%d %s" % (counts[rule_id], rule_id) for rule_id in sorted(counts)]
+        lines.append("%s reported" % ", ".join(summary))
+    return lines
+
+
+def conflict_ranges(log):
+    ranges = {}
+    for run in log["runs"]:
+        for result in run.get("results") or []:
+            for related in result.get("relatedLocations") or []:
+                physical = related.get("physicalLocation") or {}
+                uri = (physical.get("artifactLocation") or {}).get("uri")
+                region = physical.get("region") or {}
+                if uri and "byteOffset" in region and "byteLength" in region:
+                    start = region["byteOffset"]
+                    ranges.setdefault(_path(uri), []).append(
+                        (start, start + region["byteLength"])
+                    )
+    return ranges
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/source_edits.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/source_edits.py
new file mode 100644
index 00000000000000..88f3ce67339eb1
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/source_edits.py
@@ -0,0 +1,150 @@
+# 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 collections
+import re
+
+from .options import ENVIRONMENT_ERROR, Problem
+
+Replacement = collections.namedtuple("Replacement", "file_path offset length text")
+
+_ENTRY_KEYS = ("Offset", "Length", "ReplacementText")
+
+_LINE = re.compile(
+    r"^(?P<indent>(?:  - |    )?)(?P<key>[A-Za-z]+):(?: +(?P<value>.*))?$"
+)
+
+_ESCAPE = re.compile(r"\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)")
+
+_ESCAPED = {
+    "0": "\0",
+    "a": "\a",
+    "b": "\b",
+    "t": "\t",
+    "n": "\n",
+    "v": "\v",
+    "f": "\f",
+    "r": "\r",
+    "e": "\x1b",
+    '"': '"',
+    "\\": "\\",
+    "N": "\x85",
+    "_": "\xa0",
+    "L": "\u2028",
+    "P": "\u2029",
+}
+
+
+class _Malformed(Exception):
+    def __init__(self, line, message):
+        super().__init__(message)
+        self.line = line
+        self.message = message
+
+
+def _unescape(body, line):
+    pieces = []
+    position = 0
+    for match in _ESCAPE.finditer(body):
+        pieces.append(body[position : match.start()])
+        code = match.group(1)
+        if len(code) > 1:
+            pieces.append(chr(int(code[1:], 16)))
+        elif code in _ESCAPED:
+            pieces.append(_ESCAPED[code])
+        else:
+            raise _Malformed(line, "unknown escape '\\%s'" % code)
+        position = match.end()
+    rest = body[position:]
+    if "\\" in rest:
+        raise _Malformed(line, "unfinished escape")
+    pieces.append(rest)
+    return "".join(pieces)
+
+
+def _scalar(value, line):
+    if value is None:
+        raise _Malformed(line, "missing value")
+    if value[:1] in ("'", '"'):
+        if len(value) < 2 or value[-1] != value[0]:
+            raise _Malformed(line, "unterminated string")
+        if value[0] == "'":
+            return value[1:-1].replace("''", "'")
+        return _unescape(value[1:-1], line)
+    return value
+
+
+def _integer(value, line):
+    text = _scalar(value, line)
+    if not text.isdigit():
+        raise _Malformed(line, "expected a number, got '%s'" % text)
+    return int(text)
+
+
+def _parse(lines):
+    if lines and lines[-1] == "":
+        lines = lines[:-1]
+    if not lines or lines[0] != "---":
+        raise _Malformed(1, "expected '---'")
+    if len(lines) < 2 or lines[-1] != "...":
+        raise _Malformed(len(lines), "expected '...'")
+    entries = []
+    replacements_line = None
+    for number, text in enumerate(lines[1:-1], start=2):
+        match = _LINE.match(text)
+        if not match:
+            raise _Malformed(number, "unexpected line")
+        indent, key, value = match.group("indent", "key", "value")
+        if not indent and key == "MainSourceFile":
+            _scalar(value, number)
+        elif not indent and key == "Replacements" and replacements_line is None:
+            replacements_line = number
+            if value is not None and value != "[]":
+                raise _Malformed(number, "expected a list of replacements")
+        elif indent == "  - " and key == "FilePath" and replacements_line:
+            entries.append((number, {key: _scalar(value, number)}))
+        elif indent == "    " and key in _ENTRY_KEYS and entries:
+            entry = entries[-1][1]
+            if key in entry:
+                raise _Malformed(number, "'%s' given twice" % key)
+            if key == "ReplacementText":
+                entry[key] = _scalar(value, number)
+            else:
+                entry[key] = _integer(value, number)
+        else:
+            raise _Malformed(number, "unexpected '%s'" % key)
+    if replacements_line is None:
+        raise _Malformed(len(lines), "no replacements")
+    replacements = []
+    for number, entry in entries:
+        missing = [key for key in _ENTRY_KEYS if key not in entry]
+        if missing:
+            raise _Malformed(number, "replacement without '%s'" % missing[0])
+        replacements.append(
+            Replacement(
+                entry["FilePath"],
+                entry["Offset"],
+                entry["Length"],
+                entry["ReplacementText"],
+            )
+        )
+    return replacements
+
+
+def read_source_edits(path):
+    try:
+        with open(path, "rb") as handle:
+            text = handle.read().decode("utf-8")
+    except (OSError, UnicodeDecodeError) as error:
+        return None, Problem(
+            ENVIRONMENT_ERROR, "cannot read the source edits: %s" % error
+        )
+    lines = [line[:-1] if line.endswith("\r") else line for line in text.split("\n")]
+    try:
+        return _parse(lines), None
+    except _Malformed as error:
+        return None, Problem(
+            ENVIRONMENT_ERROR,
+            "malformed source edits: %s:%d: %s" % (path, error.line, error.message),
+        )
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/transformations.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/transformations.py
new file mode 100644
index 00000000000000..6a01c498a6cc87
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/transformations.py
@@ -0,0 +1,18 @@
+# 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 collections
+
+Transformation = collections.namedtuple(
+    "Transformation", "extract_summaries analysis transformation"
+)
+
+TRANSFORMATIONS = {
+    "cpp-bounded-buffers": Transformation(
+        extract_summaries="PointerFlow,TypeConstrainedPointers,UnsafeBufferUsage,"
+        "VirtualMethod",
+        analysis="UnsafeBufferReachableAnalysisResult",
+        transformation="cpp-bounded-buffers",
+    ),
+}
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/version.py.in b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/version.py.in
new file mode 100644
index 00000000000000..69b39a828847af
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/version.py.in
@@ -0,0 +1,5 @@
+# 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
+
+VERSION = "@CLANG_VERSION@"
diff --git a/clang/tools/clang-ssaf-driver/tests/__init__.py b/clang/tools/clang-ssaf-driver/tests/__init__.py
new file mode 100644
index 00000000000000..44390e67aacf47
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/__init__.py
@@ -0,0 +1,3 @@
+# 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
diff --git a/clang/tools/clang-ssaf-driver/tests/test_driver.py b/clang/tools/clang-ssaf-driver/tests/test_driver.py
new file mode 100644
index 00000000000000..99471d30def988
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_driver.py
@@ -0,0 +1,123 @@
+# 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 io
+import json
+import os
+import shutil
+import tempfile
+import unittest
+from unittest import mock
+
+from clang_ssaf_driver import driver, make_orchestrator
+
+
+class Running(unittest.TestCase):
+    def setUp(self):
+        self.tmp = os.path.realpath(tempfile.mkdtemp())
+        self.addCleanup(shutil.rmtree, self.tmp, True)
+        self.project = os.path.join(self.tmp, "project")
+        os.makedirs(os.path.join(self.project, "src"))
+        self.addCleanup(os.chdir, os.getcwd())
+        os.chdir(self.project)
+        with open("src/a.cpp", "wb") as handle:
+            handle.write(b"int *p;\n")
+        self.out = os.path.join(self.project, "out")
+        os.makedirs(self.out)
+        orchestrator = os.path.join(self.tmp, "clang-ssaf-orchestrator-make")
+        open(orchestrator, "w").close()
+        for name, value in (
+            ("find_orchestrator", mock.Mock(return_value=orchestrator)),
+            ("run_orchestrator", mock.Mock(return_value=(0, self._results()))),
+        ):
+            patcher = mock.patch.object(make_orchestrator, name, value)
+            patcher.start()
+            self.addCleanup(patcher.stop)
+
+    def _results(self):
+        edits = os.path.join(self.tmp, "prg.edits.yaml")
+        with open(edits, "w") as handle:
+            handle.write(
+                "---\n"
+                "MainSourceFile:  ''\n"
+                "Replacements:\n"
+                '  - FilePath:        "src/a.cpp"\n'
+                "    Offset:          0\n"
+                "    Length:          5\n"
+                '    ReplacementText: "bounded_ptr<int> "\n'
+                "...\n"
+            )
+        report = os.path.join(self.tmp, "prg.report.sarif")
+        with open(report, "w") as handle:
+            json.dump(
+                {
+                    "runs": [
+                        {
+                            "results": [
+                                {
+                                    "level": "note",
+                                    "message": {"text": "m"},
+                                    "ruleId": "r",
+                                }
+                            ]
+                        }
+                    ]
+                },
+                handle,
+            )
+        return {"source-edits": edits, "transformation-report": report}
+
+    def _main(self, *extra):
+        stdout, stderr = io.StringIO(), io.StringIO()
+        with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
+            status = driver.main(
+                ["--transformation=cpp-bounded-buffers", "-o", "out"]
+                + list(extra)
+                + ["--", "make"]
+            )
+        return status, stdout.getvalue(), stderr.getvalue()
+
+    def test_a_run_writes_the_patch_and_the_report_and_names_them(self):
+        status, stdout, stderr = self._main()
+        self.assertEqual(0, status)
+        patch_path = os.path.join(self.out, "cpp-bounded-buffers.patch")
+        report_path = os.path.join(self.out, "cpp-bounded-buffers.sarif")
+        self.assertEqual("patch: %s\nreport: %s\n" % (patch_path, report_path), stdout)
+        self.assertEqual("clang-ssaf-driver: note: m [r]\n1 r reported\n", stderr)
+        with open(patch_path, "rb") as handle:
+            self.assertIn(b"+bounded_ptr<int> p;\n", handle.read())
+        with open(report_path) as handle:
+            self.assertEqual(1, len(json.load(handle)["runs"]))
+        with open("src/a.cpp", "rb") as handle:
+            self.assertEqual(b"int *p;\n", handle.read())
+
+    def test_a_failed_orchestrator_run_writes_nothing(self):
+        make_orchestrator.run_orchestrator.return_value = (2, {})
+        status, stdout, stderr = self._main()
+        self.assertEqual(2, status)
+        self.assertEqual("", stdout)
+        self.assertEqual(
+            "clang-ssaf-driver: error: clang-ssaf-orchestrator-make failed (exit 2)\n",
+            stderr,
+        )
+        self.assertEqual([], os.listdir(self.out))
+
+    def test_a_run_without_source_edits_is_a_problem(self):
+        make_orchestrator.run_orchestrator.return_value = (0, {})
+        status, _, stderr = self._main()
+        self.assertEqual(1, status)
+        self.assertIn("reported no source edits", stderr)
+
+    def test_a_missing_orchestrator_stops_the_run_before_it_starts(self):
+        make_orchestrator.find_orchestrator.return_value = os.path.join(
+            self.tmp, "nothing-here"
+        )
+        status, _, stderr = self._main()
+        self.assertEqual(1, status)
+        self.assertIn("cannot find clang-ssaf-orchestrator-make", stderr)
+        make_orchestrator.run_orchestrator.assert_not_called()
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_make_orchestrator.py b/clang/tools/clang-ssaf-driver/tests/test_make_orchestrator.py
new file mode 100644
index 00000000000000..ec406fc4051f2c
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_make_orchestrator.py
@@ -0,0 +1,137 @@
+# 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 io
+import os
+import shutil
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+from clang_ssaf_driver import make_orchestrator, options
+from clang_ssaf_driver.transformations import TRANSFORMATIONS
+
+
+class Command(unittest.TestCase):
+    def test_the_orchestrator_runs_the_recipe_under_this_python(self):
+        command_line_opts, _ = options.resolve(
+            [
+                "--transformation=cpp-bounded-buffers",
+                "-o",
+                "out",
+                "--ssaf-clang=/x/clang",
+                "make",
+                "-j8",
+            ]
+        )
+        self.assertEqual(
+            [
+                sys.executable,
+                "/prefix/bin/clang-ssaf-orchestrator-make",
+                "--extract-summaries=PointerFlow,TypeConstrainedPointers,"
+                "UnsafeBufferUsage,VirtualMethod",
+                "--analysis=UnsafeBufferReachableAnalysisResult",
+                "--transformation=cpp-bounded-buffers",
+                "--output-dir=out",
+                "--ssaf-clang=/x/clang",
+                "--",
+                "make",
+                "-j8",
+            ],
+            make_orchestrator.orchestrator_command(
+                "/prefix/bin/clang-ssaf-orchestrator-make",
+                TRANSFORMATIONS["cpp-bounded-buffers"],
+                command_line_opts,
+            ),
+        )
+
+    def test_the_orchestrator_is_the_one_installed_beside_the_driver(self):
+        with mock.patch.object(
+            make_orchestrator,
+            "__file__",
+            "/prefix/lib/clang_ssaf_driver/make_orchestrator.py",
+        ):
+            self.assertEqual(
+                "/prefix/bin/clang-ssaf-orchestrator-make",
+                make_orchestrator.find_orchestrator(),
+            )
+
+
+class Results(unittest.TestCase):
+    def test_a_result_line_is_read(self):
+        self.assertEqual(
+            ("source-edits", "/out/prg.edits.yaml"),
+            make_orchestrator.read_result(b"source-edits: /out/prg.edits.yaml\n"),
+        )
+
+    def test_nothing_else_is(self):
+        for line in (
+            b"clang++ -c -o build/a.o src/a.cpp\n",
+            b"link-unit: /p/prg\n",
+            b"source-edits:\n",
+            b"make: source-edits: /x\n",
+        ):
+            self.assertIsNone(make_orchestrator.read_result(line))
+
+    def test_a_path_that_is_not_utf8_survives(self):
+        _, path = make_orchestrator.read_result(b"source-edits: /out/\xff.yaml\n")
+        self.assertEqual(b"/out/\xff.yaml", os.fsencode(path))
+
+
+class Running(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.tmp, True)
+
+    def _run(self, body):
+        script = os.path.join(self.tmp, "orchestrator")
+        with open(script, "w") as handle:
+            handle.write("import os, signal, sys\nout = sys.stdout.buffer\n" + body)
+        relay = io.BytesIO()
+        status, results = make_orchestrator.run_orchestrator(
+            [sys.executable, script], False, relay
+        )
+        return status, results, relay.getvalue()
+
+    def test_everything_is_relayed_and_the_last_results_are_kept(self):
+        printed = (
+            b"make: entering\n"
+            b"source-edits: /old\n"
+            b"\xff not utf8\n"
+            b"source-edits: /out/prg.edits.yaml\n"
+            b"transformation-report: /out/prg.report.sarif\n"
+            b"conflict-report: /out/prg.conflict-report.sarif\n"
+            b"output-dir: /out\n"
+        )
+        status, results, relayed = self._run("out.write(%r)\n" % printed)
+        self.assertEqual(0, status)
+        self.assertEqual(printed, relayed)
+        self.assertEqual(
+            {
+                "source-edits": "/out/prg.edits.yaml",
+                "transformation-report": "/out/prg.report.sarif",
+                "conflict-report": "/out/prg.conflict-report.sarif",
+            },
+            results,
+        )
+
+    def test_the_exit_status_passes_through(self):
+        status, _, _ = self._run("sys.exit(3)\n")
+        self.assertEqual(3, status)
+
+    def test_death_by_a_signal_is_the_shells_status_for_it(self):
+        status, _, _ = self._run("os.kill(os.getpid(), signal.SIGTERM)\n")
+        self.assertEqual(128 + 15, status)
+
+    def test_a_result_glued_to_the_builds_last_output_is_not_read(self):
+        printed = b"no newline" + b"source-edits: /x\n"
+        status, results, relayed = self._run("out.write(%r)\n" % printed)
+        self.assertEqual(0, status)
+        self.assertEqual(printed, relayed)
+        self.assertEqual({}, results)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_options.py b/clang/tools/clang-ssaf-driver/tests/test_options.py
new file mode 100644
index 00000000000000..4c01684748f8db
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_options.py
@@ -0,0 +1,72 @@
+# 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 io
+import unittest
+from unittest import mock
+
+from clang_ssaf_driver import options
+
+
+class Resolve(unittest.TestCase):
+    def _resolve(self, *extra):
+        return options.resolve(["--transformation=cpp-bounded-buffers"] + list(extra))
+
+    def test_a_valid_command_line_yields_the_options_as_typed(self):
+        command_line_opts, problem = self._resolve("-o", "out", "--", "make", "-j8")
+        self.assertIsNone(problem)
+        self.assertEqual(["make", "-j8"], command_line_opts.build_command)
+        self.assertEqual("cpp-bounded-buffers", command_line_opts.transformation)
+        self.assertEqual("out", command_line_opts.output_dir)
+        self.assertEqual([], command_line_opts.orchestrator_args)
+
+    def test_the_separator_before_the_build_command_is_optional(self):
+        command_line_opts, problem = self._resolve("make", "-j8")
+        self.assertIsNone(problem)
+        self.assertEqual(["make", "-j8"], command_line_opts.build_command)
+
+    def test_an_option_after_the_build_command_is_the_builds(self):
+        command_line_opts, _ = self._resolve("make", "--verbose")
+        self.assertEqual(["make", "--verbose"], command_line_opts.build_command)
+        self.assertFalse(command_line_opts.verbose)
+
+    def test_no_build_command_is_a_usage_error(self):
+        _, problem = self._resolve("--")
+        self.assertEqual(options.USAGE_ERROR, problem.exit_status)
+        self.assertEqual("no build command given", problem.message)
+
+    def test_the_transformation_is_required_and_must_be_known(self):
+        for argv in (["--", "make"], ["--transformation=nonexistent", "--", "make"]):
+            with mock.patch("sys.stderr", io.StringIO()):
+                with self.assertRaises(SystemExit) as raised:
+                    options.resolve(argv)
+            self.assertEqual(options.USAGE_ERROR, raised.exception.code)
+
+    def test_the_orchestrator_gets_each_forwarded_option_with_its_value(self):
+        command_line_opts, _ = self._resolve(
+            "--ssaf-clang=/x/clang",
+            "--ssaf-conflict-merge",
+            "/x/merge",
+            "--ssaf-extra-clang-arg=-DX",
+            "--ssaf-extra-clang-arg=-DY",
+            "-k",
+            "-v",
+            "--",
+            "make",
+        )
+        self.assertEqual(
+            [
+                "--ssaf-clang=/x/clang",
+                "--ssaf-conflict-merge=/x/merge",
+                "--ssaf-extra-clang-arg=-DX",
+                "--ssaf-extra-clang-arg=-DY",
+                "--keep-going",
+                "--verbose",
+            ],
+            command_line_opts.orchestrator_args,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_patch.py b/clang/tools/clang-ssaf-driver/tests/test_patch.py
new file mode 100644
index 00000000000000..758455f36e66c1
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_patch.py
@@ -0,0 +1,249 @@
+# 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 os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+from clang_ssaf_driver import patch
+from clang_ssaf_driver.source_edits import Replacement
+
+
+def _has_gnu_or_bsd_patch():
+    try:
+        version = subprocess.run(
+            ["patch", "--version"],
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+        )
+    except OSError:
+        return False
+    return version.returncode == 0
+
+
+class ApplyingEdits(unittest.TestCase):
+    def _apply(self, original, edits):
+        return patch.apply_edits(original, sorted(edits))
+
+    def test_replacements_deletions_and_insertions(self):
+        self.assertEqual(
+            (b"bounded_ptr<int> p; q[4]/*T*/;", None),
+            self._apply(
+                b"int *p; q[4];",
+                [(0, 5, "bounded_ptr<int> "), (12, 0, "/*T*/")],
+            ),
+        )
+        self.assertEqual((b"int q;", None), self._apply(b"int q[4];", [(5, 3, "")]))
+
+    def test_an_insertion_goes_before_a_replacement_at_its_offset(self):
+        self.assertEqual(
+            (b"(bounded_ptr<int> )p", None),
+            self._apply(b"int *p", [(0, 0, "("), (0, 5, "bounded_ptr<int> )")]),
+        )
+
+    def test_an_insertion_at_the_end_of_a_replacement_follows_it(self):
+        self.assertEqual((b"Yx", None), self._apply(b"ab", [(0, 2, "Y"), (2, 0, "x")]))
+
+    def test_overlapping_edits_are_refused(self):
+        _, problem = self._apply(b"abcdef", [(0, 3, "x"), (2, 2, "y")])
+        self.assertEqual("edits overlap at byte 2", problem)
+        _, problem = self._apply(b"abcdef", [(1, 0, "x"), (1, 0, "y")])
+        self.assertEqual("edits overlap at byte 1", problem)
+
+    def test_an_edit_past_the_end_is_refused(self):
+        _, problem = self._apply(b"abc", [(2, 2, "x")])
+        self.assertEqual("an edit at byte 2 runs past the end of the file", problem)
+
+    def test_the_text_goes_in_as_utf8(self):
+        self.assertEqual(
+            ("éb".encode("utf-8"), None), self._apply(b"ab", [(0, 1, "é")])
+        )
+
+
+class Collecting(unittest.TestCase):
+    def setUp(self):
+        self.tmp = os.path.realpath(tempfile.mkdtemp())
+        self.addCleanup(shutil.rmtree, self.tmp, True)
+        self.root = os.path.join(self.tmp, "project")
+        os.makedirs(os.path.join(self.root, "src"))
+
+    def _file(self, relative, content):
+        path = os.path.join(self.root, relative)
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+        with open(path, "wb") as handle:
+            handle.write(content)
+        return path
+
+    def _collect(self, replacements, conflicts=None):
+        collected, problem = patch.collect(replacements, self.root, conflicts or {})
+        self.assertIsNone(problem)
+        return collected
+
+    def test_edits_are_grouped_by_file_and_sorted(self):
+        self._file("src/b.cpp", b"int *b;\n")
+        self._file("src/a.cpp", b"int *a;\n")
+        collected = self._collect(
+            [
+                Replacement("src/b.cpp", 0, 5, "bounded_ptr<int> "),
+                Replacement("src/a.cpp", 0, 5, "bounded_ptr<int> "),
+            ]
+        )
+        self.assertEqual(
+            ["src/a.cpp", "src/b.cpp"], [f.relative_path for f in collected.files]
+        )
+        self.assertEqual(b"bounded_ptr<int> a;\n", collected.files[0].edited)
+        self.assertEqual(([], []), (collected.outside, collected.blocked))
+
+    def test_every_spelling_of_a_file_is_one_file_and_an_edit_counts_once(self):
+        header = self._file("src/h.h", b"int *g;\n")
+        os.link(header, os.path.join(self.root, "src", "linked.h"))
+        edit = (0, 5, "bounded_ptr<int> ")
+        collected = self._collect(
+            [
+                Replacement("src/h.h", *edit),
+                Replacement("src/../src/h.h", *edit),
+                Replacement(header, *edit),
+                Replacement("src/linked.h", *edit),
+            ]
+        )
+        self.assertEqual(["src/h.h"], [f.relative_path for f in collected.files])
+        self.assertEqual([edit], collected.files[0].edits)
+
+    def test_an_edit_that_overlaps_a_reported_conflict_is_left_out(self):
+        header = self._file("src/h.h", b"0123456789abcdef")
+        collected = self._collect(
+            [
+                Replacement("src/h.h", 0, 2, "A"),
+                Replacement("src/h.h", 2, 0, "B"),
+                Replacement("src/h.h", 4, 4, "C"),
+                Replacement("src/h.h", 7, 0, "D"),
+                Replacement("src/h.h", 10, 2, "E"),
+            ],
+            {header: [(2, 9)]},
+        )
+        self.assertEqual(
+            [(0, 2, "A"), (2, 0, "B"), (10, 2, "E")], collected.files[0].edits
+        )
+        self.assertEqual([("src/h.h", 2)], collected.blocked)
+
+    def test_a_file_whose_edits_all_conflict_is_left_out(self):
+        header = self._file("src/h.h", b"int *g;\n")
+        collected = self._collect(
+            [Replacement("src/h.h", 0, 5, "bounded_ptr<int> ")], {header: [(0, 5)]}
+        )
+        self.assertEqual([], collected.files)
+        self.assertEqual([("src/h.h", 1)], collected.blocked)
+
+    def test_a_file_outside_the_project_is_left_out(self):
+        self._file("src/a.cpp", b"int *a;\n")
+        elsewhere = os.path.join(self.tmp, "elsewhere.h")
+        with open(elsewhere, "wb") as handle:
+            handle.write(b"int *x;\n")
+        collected = self._collect(
+            [
+                Replacement("src/a.cpp", 0, 5, "bounded_ptr<int> "),
+                Replacement(elsewhere, 0, 5, "bounded_ptr<int> "),
+            ]
+        )
+        self.assertEqual(["src/a.cpp"], [f.relative_path for f in collected.files])
+        self.assertEqual([elsewhere], collected.outside)
+
+    def test_every_file_that_cannot_be_edited_is_reported_at_once(self):
+        self._file("src/a.cpp", b"abc")
+        collected, problem = patch.collect(
+            [
+                Replacement("src/a.cpp", 2, 5, "x"),
+                Replacement("src/missing.cpp", 0, 0, "x"),
+            ],
+            self.root,
+            {},
+        )
+        self.assertIsNone(collected)
+        self.assertEqual("the source edits cannot be applied:", problem.message)
+        self.assertEqual(
+            [
+                "src/a.cpp: an edit at byte 2 runs past the end of the file",
+                "src/missing.cpp: No such file or directory",
+            ],
+            list(problem.details),
+        )
+
+
+class Rendering(unittest.TestCase):
+    def setUp(self):
+        self.tmp = os.path.realpath(tempfile.mkdtemp())
+        self.addCleanup(shutil.rmtree, self.tmp, True)
+
+    def _render(self, original, edits, name="src/a.cpp"):
+        edited, problem = patch.apply_edits(original, edits)
+        self.assertIsNone(problem)
+        path = os.path.join(self.tmp, name)
+        return patch.render([patch.FileEdits(name, path, edits, original, edited)])
+
+    def test_a_unified_diff_with_paths_under_a_and_b(self):
+        self.assertEqual(
+            b"--- a/src/a.cpp\n"
+            b"+++ b/src/a.cpp\n"
+            b"@@ -1,3 +1,3 @@\n"
+            b" int f();\n"
+            b"-int *p;\n"
+            b"+bounded_ptr<int> p;\n"
+            b" int g();\n",
+            self._render(
+                b"int f();\nint *p;\nint g();\n", [(9, 5, "bounded_ptr<int> ")]
+            ),
+        )
+
+    def test_a_missing_final_newline_is_marked(self):
+        self.assertEqual(
+            b"--- a/src/a.cpp\n"
+            b"+++ b/src/a.cpp\n"
+            b"@@ -1 +1 @@\n"
+            b"-int *p;\n"
+            b"\\ No newline at end of file\n"
+            b"+bounded_ptr<int> p;\n"
+            b"\\ No newline at end of file\n",
+            self._render(b"int *p;", [(0, 5, "bounded_ptr<int> ")]),
+        )
+
+    def test_line_ends_and_bytes_are_kept_as_they_are(self):
+        rendered = self._render(
+            b"\xff\r\nint *p;\r\nold\rmac\n", [(3, 5, "bounded_ptr<int> ")]
+        )
+        self.assertIn(b"-int *p;\r\n+bounded_ptr<int> p;\r\n", rendered)
+        self.assertIn(b" old\rmac\n", rendered)
+
+    def test_no_edits_is_an_empty_patch(self):
+        self.assertEqual(b"", patch.render([]))
+
+    @unittest.skipUnless(_has_gnu_or_bsd_patch(), "needs a GNU or BSD patch")
+    def test_patch_applies_what_was_rendered(self):
+        cases = {
+            "src/a.cpp": (b"int f();\nint *p;\nint g();\n", [(9, 5, "q ")]),
+            "src/b.cpp": (b"int *p;", [(0, 5, "bounded_ptr<int> ")]),
+            "src/c.cpp": (b"a\r\nint *p;\r\nb\rc\n", [(3, 5, "x")]),
+        }
+        files = []
+        for name, (original, edits) in sorted(cases.items()):
+            path = os.path.join(self.tmp, name)
+            os.makedirs(os.path.dirname(path), exist_ok=True)
+            with open(path, "wb") as handle:
+                handle.write(original)
+            edited, _ = patch.apply_edits(original, edits)
+            files.append(patch.FileEdits(name, path, edits, original, edited))
+        subprocess.run(
+            ["patch", "-p1", "-s"],
+            input=patch.render(files),
+            cwd=self.tmp,
+            check=True,
+        )
+        for each in files:
+            with open(each.path, "rb") as handle:
+                self.assertEqual(each.edited, handle.read())
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_report.py b/clang/tools/clang-ssaf-driver/tests/test_report.py
new file mode 100644
index 00000000000000..ba60c6928590e6
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_report.py
@@ -0,0 +1,204 @@
+# 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 json
+import os
+import shutil
+import tempfile
+import unittest
+
+from clang_ssaf_driver import report
+
+
+def _location(uri, **region):
+    return {"physicalLocation": {"artifactLocation": {"uri": uri}, "region": region}}
+
+
+def _log(*results):
+    return {"runs": [{"results": list(results)}]}
+
+
+class Loading(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.tmp, True)
+
+    def _write(self, name, document):
+        path = os.path.join(self.tmp, name)
+        with open(path, "w") as handle:
+            if isinstance(document, str):
+                handle.write(document)
+            else:
+                json.dump(document, handle)
+        return path
+
+    def test_the_runs_of_both_reports_make_one_log(self):
+        transformation = self._write(
+            "t.sarif", {"runs": [{"tool": {"driver": {"name": "clang-ssaf"}}}]}
+        )
+        conflicts = self._write(
+            "c.sarif",
+            {"runs": [{"tool": {"driver": {"name": "clang-ssaf-src-edit-merge"}}}]},
+        )
+        log, problem = report.load([transformation, conflicts])
+        self.assertIsNone(problem)
+        self.assertEqual("2.1.0", log["version"])
+        self.assertEqual(
+            ["clang-ssaf", "clang-ssaf-src-edit-merge"],
+            [run["tool"]["driver"]["name"] for run in log["runs"]],
+        )
+
+    def test_an_absent_report_adds_nothing(self):
+        conflicts = self._write("c.sarif", {"runs": [{"results": []}]})
+        log, problem = report.load([None, conflicts])
+        self.assertIsNone(problem)
+        self.assertEqual([{"results": []}], log["runs"])
+
+    def test_what_is_not_a_sarif_log_is_a_problem(self):
+        _, problem = report.load([self._write("list.sarif", "[]")])
+        self.assertIn("not a SARIF log", problem.message)
+        _, problem = report.load([self._write("bad.sarif", "{")])
+        self.assertIn("cannot read the report", problem.message)
+
+    def test_the_log_is_written_as_json(self):
+        log, _ = report.load([])
+        path = os.path.join(self.tmp, "out.sarif")
+        report.write(path, log)
+        with open(path) as handle:
+            self.assertEqual(log, json.load(handle))
+
+
+class Diagnostics(unittest.TestCase):
+    def setUp(self):
+        self.root = os.path.realpath(tempfile.mkdtemp())
+        self.addCleanup(shutil.rmtree, self.root, True)
+        os.makedirs(os.path.join(self.root, "src"))
+        with open(os.path.join(self.root, "src", "shared.h"), "wb") as handle:
+            handle.write(b"ab\ncdef\n")
+        self.shared = "file://" + os.path.join(self.root, "src", "shared.h")
+
+    def test_a_finding_reads_like_a_compiler_diagnostic(self):
+        log = _log(
+            {
+                "level": "note",
+                "locations": [_location(self.shared, startLine=3, startColumn=7)],
+                "message": {"text": "not yet rewritten"},
+                "ruleId": "cpp-bounded-buffers-skipped",
+            }
+        )
+        self.assertEqual(
+            [
+                "src/shared.h:3:7: note: not yet rewritten "
+                "[cpp-bounded-buffers-skipped]",
+                "1 cpp-bounded-buffers-skipped reported",
+            ],
+            report.diagnostics(log, self.root),
+        )
+
+    def test_a_conflict_is_placed_by_its_bytes_and_followed_by_its_candidates(self):
+        log = _log(
+            {
+                "level": "error",
+                "locations": [_location(self.shared, byteOffset=5)],
+                "message": {"text": "dropped"},
+                "relatedLocations": [
+                    dict(
+                        _location(self.shared, byteOffset=3, byteLength=2),
+                        message={"text": 'candidate edit: "x"'},
+                    )
+                ],
+                "ruleId": "conflict",
+            }
+        )
+        self.assertEqual(
+            [
+                "src/shared.h:2:3: error: dropped [conflict]",
+                'src/shared.h:2:1: note: candidate edit: "x"',
+                "1 conflict reported",
+            ],
+            report.diagnostics(log, self.root),
+        )
+
+    def test_a_file_outside_the_project_keeps_its_whole_path(self):
+        log = _log(
+            {
+                "level": "note",
+                "locations": [_location("file:///usr/include/my%20lib.h", startLine=1)],
+                "message": {"text": "m"},
+                "ruleId": "r",
+            }
+        )
+        self.assertEqual(
+            "/usr/include/my lib.h:1:1: note: m [r]",
+            report.diagnostics(log, self.root)[0],
+        )
+
+    def test_a_finding_without_a_location_is_the_drivers_own(self):
+        self.assertEqual(
+            "clang-ssaf-driver: warning: m [r]",
+            report.diagnostics(_log({"message": {"text": "m"}, "ruleId": "r"}), "/")[0],
+        )
+
+    def test_the_findings_are_counted_per_rule(self):
+        finding = {"locations": [_location(self.shared, startLine=1)]}
+        log = {
+            "runs": [
+                {"results": [dict(finding, ruleId="b"), dict(finding, ruleId="a")]},
+                {"results": [dict(finding, ruleId="b")]},
+            ]
+        }
+        self.assertEqual("1 a, 2 b reported", report.diagnostics(log, self.root)[-1])
+
+    def test_no_findings_print_nothing(self):
+        self.assertEqual([], report.diagnostics(_log(), self.root))
+
+    def test_a_merge_tools_path_is_read_as_written(self):
+        path = os.path.join(self.root, "src", "a#b?c.h")
+        with open(path, "wb") as handle:
+            handle.write(b"x\ny\n")
+        log = _log(
+            {
+                "locations": [_location("file://" + path, byteOffset=2)],
+                "message": {"text": "m"},
+                "ruleId": "r",
+            }
+        )
+        self.assertEqual(
+            "src/a#b?c.h:2:1: warning: m [r]", report.diagnostics(log, self.root)[0]
+        )
+
+
+class ConflictRanges(unittest.TestCase):
+    def test_every_candidate_of_every_conflict_is_a_range(self):
+        log = {
+            "runs": [
+                {"results": [{"locations": [_location("file:///p/a.h", startLine=1)]}]},
+                {
+                    "results": [
+                        {
+                            "locations": [_location("file:///p/h.h", byteOffset=4)],
+                            "relatedLocations": [
+                                _location("file:///p/h.h", byteOffset=4, byteLength=3),
+                                _location("file:///p/h.h", byteOffset=6, byteLength=9),
+                            ],
+                        },
+                        {
+                            "relatedLocations": [
+                                _location(
+                                    "file:///p/my%20c.h", byteOffset=0, byteLength=1
+                                )
+                            ]
+                        },
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(
+            {"/p/h.h": [(4, 7), (6, 15)], "/p/my c.h": [(0, 1)]},
+            report.conflict_ranges(log),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_source_edits.py b/clang/tools/clang-ssaf-driver/tests/test_source_edits.py
new file mode 100644
index 00000000000000..d40122b827c973
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_source_edits.py
@@ -0,0 +1,141 @@
+# 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 os
+import shutil
+import tempfile
+import unittest
+
+from clang_ssaf_driver import source_edits
+from clang_ssaf_driver.source_edits import Replacement
+
+
+def _entry(file_path, offset, length, text):
+    return [
+        "  - FilePath:        %s" % file_path,
+        "    Offset:          %d" % offset,
+        "    Length:          %d" % length,
+        "    ReplacementText: %s" % text,
+    ]
+
+
+class Reading(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.tmp, True)
+        self.path = os.path.join(self.tmp, "edits.yaml")
+
+    def _read(self, lines, line_end=b"\n"):
+        with open(self.path, "wb") as handle:
+            handle.write(line_end.join(line.encode("utf-8") for line in lines))
+            handle.write(line_end)
+        return source_edits.read_source_edits(self.path)
+
+    def _document(self, *entries):
+        lines = ["---", "MainSourceFile:  ''", "Replacements:"]
+        for entry in entries:
+            lines.extend(entry)
+        return lines + ["..."]
+
+    def test_every_scalar_the_merge_tool_writes_is_read(self):
+        written = [
+            ("plain", "plain"),
+            ("a\tb", "a\tb"),
+            ("' lead'", " lead"),
+            ("'it''s'", "it's"),
+            ("''", ""),
+            ("'4'", "4"),
+            ("'true'", "true"),
+            ('"line\\nbreak"', "line\nbreak"),
+            ("'tab\tand\"quote\\'", 'tab\tand"quote\\'),
+            ('"\\x01\x7f"', "\x01\x7f"),
+            ('"\u00e9"', "\u00e9"),
+            ('"\U0001F600"', "\U0001F600"),
+            ('"\\L\\N\\_"', "\u2028\x85\xa0"),
+        ]
+        replacements, problem = self._read(
+            self._document(
+                *[
+                    _entry("'src/f.cpp'", index * 5, 1, text)
+                    for index, (text, _) in enumerate(written)
+                ]
+            )
+        )
+        self.assertIsNone(problem)
+        self.assertEqual(
+            [
+                Replacement("src/f.cpp", index * 5, 1, text)
+                for index, (_, text) in enumerate(written)
+            ],
+            replacements,
+        )
+
+    def test_every_escape_of_a_double_quoted_scalar_is_read(self):
+        replacements, problem = self._read(
+            self._document(
+                _entry(
+                    "f.cpp",
+                    0,
+                    0,
+                    '"\\0\\a\\b\\t\\n\\v\\f\\r\\e\\"\\\\\\x41\\u00e9\\U0001F600"',
+                )
+            )
+        )
+        self.assertIsNone(problem)
+        self.assertEqual(
+            '\0\a\b\t\n\v\f\r\x1b"\\A\u00e9\U0001F600', replacements[0].text
+        )
+
+    def test_no_replacements_is_an_empty_list(self):
+        replacements, problem = self._read(
+            ["---", "MainSourceFile:  ''", "Replacements:    []", "..."]
+        )
+        self.assertIsNone(problem)
+        self.assertEqual([], replacements)
+
+    def test_crlf_line_ends_are_read(self):
+        replacements, problem = self._read(
+            self._document(_entry("f.cpp", 1, 2, "x")), line_end=b"\r\n"
+        )
+        self.assertIsNone(problem)
+        self.assertEqual([Replacement("f.cpp", 1, 2, "x")], replacements)
+
+    def _problem(self, lines):
+        replacements, problem = self._read(lines)
+        self.assertIsNone(replacements)
+        return problem.message
+
+    def test_a_truncated_file_is_malformed(self):
+        self.assertEqual(
+            "malformed source edits: %s:7: expected '...'" % self.path,
+            self._problem(self._document(_entry("f.cpp", 1, 2, "x"))[:-1]),
+        )
+
+    def test_a_malformed_line_is_reported_with_its_number(self):
+        lines = self._document(_entry("f.cpp", 1, 2, "x"))
+        lines[4] = "    Offset:          one"
+        self.assertEqual(
+            "malformed source edits: %s:5: expected a number, got 'one'" % self.path,
+            self._problem(lines),
+        )
+
+    def test_an_unknown_escape_is_malformed(self):
+        self.assertIn(
+            "unknown escape '\\q'",
+            self._problem(self._document(_entry("f.cpp", 1, 2, '"\\q"'))),
+        )
+
+    def test_a_replacement_missing_a_field_is_malformed(self):
+        self.assertIn(
+            "replacement without 'ReplacementText'",
+            self._problem(self._document(_entry("f.cpp", 1, 2, "x")[:3])),
+        )
+
+    def test_a_file_that_cannot_be_read_is_a_problem(self):
+        _, problem = source_edits.read_source_edits(self.path)
+        self.assertIn("cannot read the source edits", problem.message)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/llvm/utils/gn/secondary/clang/test/BUILD.gn b/llvm/utils/gn/secondary/clang/test/BUILD.gn
index e4cb35528cf8bb..1da964cb6933ea 100644
--- a/llvm/utils/gn/secondary/clang/test/BUILD.gn
+++ b/llvm/utils/gn/secondary/clang/test/BUILD.gn
@@ -166,6 +166,7 @@ group("test") {
     "//clang/tools/clang-scan-deps",
     "//clang/tools/clang-ssaf-analyzer",
     "//clang/tools/clang-ssaf-conflict-merge",
+    "//clang/tools/clang-ssaf-driver",
     "//clang/tools/clang-ssaf-format",
     "//clang/tools/clang-ssaf-linker",
     "//clang/tools/clang-ssaf-orchestrator-make",
diff --git a/llvm/utils/gn/secondary/clang/tools/clang-ssaf-driver/BUILD.gn b/llvm/utils/gn/secondary/clang/tools/clang-ssaf-driver/BUILD.gn
new file mode 100644
index 00000000000000..5f37317eb6061f
--- /dev/null
+++ b/llvm/utils/gn/secondary/clang/tools/clang-ssaf-driver/BUILD.gn
@@ -0,0 +1,40 @@
+import("//llvm/utils/gn/build/write_cmake_config.gni")
+import("//llvm/version.gni")
+
+# Python tool. Copies its scripts into the build dir, like scan-build.
+group("clang-ssaf-driver") {
+  deps = [
+    ":bin",
+    ":lib",
+    ":version",
+
+    # The driver runs the orchestrator installed beside it.
+    "//clang/tools/clang-ssaf-orchestrator-make",
+  ]
+}
+
+copy("bin") {
+  sources = [ "bin/clang-ssaf-driver" ]
+  outputs = [ "$root_build_dir/bin/{{source_file_part}}" ]
+}
+
+copy("lib") {
+  sources = [
+    "lib/clang_ssaf_driver/__init__.py",
+    "lib/clang_ssaf_driver/driver.py",
+    "lib/clang_ssaf_driver/logging_diagnostics.py",
+    "lib/clang_ssaf_driver/make_orchestrator.py",
+    "lib/clang_ssaf_driver/options.py",
+    "lib/clang_ssaf_driver/patch.py",
+    "lib/clang_ssaf_driver/report.py",
+    "lib/clang_ssaf_driver/source_edits.py",
+    "lib/clang_ssaf_driver/transformations.py",
+  ]
+  outputs = [ "$root_build_dir/lib/clang_ssaf_driver/{{source_file_part}}" ]
+}
+
+write_cmake_config("version") {
+  input = "lib/clang_ssaf_driver/version.py.in"
+  output = "$root_build_dir/lib/clang_ssaf_driver/version.py"
+  values = [ "CLANG_VERSION=$llvm_version" ]
+}



More information about the llvm-branch-commits mailing list